Architecture overview - usersaynoso/cactus-foundation GitHub Wiki
This page is a technical reference for developers building on or contributing to Cactus Foundation. If you're looking to manage your site day-to-day, see the site owner guides instead.
Browser request
โ
โผ
proxy.ts (Node.js runtime - NOT Edge)
โ
โโโ Always pass: /api/health, /api/webhooks/, /_next/, /favicon.ico, /api/branding/
โ
โโโ Setup gate: if SiteConfig.setupCompleted = false
โ โ only /_setup and /api/setup/* pass through
โ โ everything else redirects to /_setup
โ โ if the READ fails on a site that is already set up
โ โ 503 "Back in a moment", never the wizard
โ
โโโ Admin path enforcement
โ โ request matches /<adminPath>[/*] ?
โ yes โ rewrite to /_cactus_admin[/*]
โ (validate session; redirect to /<adminPath>/login if missing)
โ no โ falls through (404 from Next.js for unknown routes)
โ
โโโ Member area rewrite
โ โ request matches /<memberAreaPath>[/*] ?
โ members system disabled โ 404
โ yes โ rewrite to /_cactus_member_area[/*]
โ (sets x-cactus-member-full-path so the layout can build
โ an accurate post-login redirect target)
โ
โโโ Site status gate (public routes only)
โ โ status = live? โ pass through
โ โ status โ live and requester has admin session? โ pass through
โ โ status = comingSoon โ rewrite to /_status/coming-soon
โ โ status = maintenance โ rewrite to /_status/maintenance
โ
โโโ Members-only site access gate (public routes only)
โ resolve route tier: site-wide members-only setting, OR a
module-declared route tier (PUBLIC / MEMBER / TRUSTED_MEMBER)
for this path
โ tier = PUBLIC and site-wide gate off โ pass through
โ path is a declared site-wide exception โ pass through
โ requester has an admin session โ pass through
โ requester has a valid member session (trusted, if required)?
yes โ pass through
no โ guest preview enabled? โ pass through, flagged
no member session at all? โ redirect to member login
signed in but insufficiently privileged? โ 404
โ
โโโ Page cache decision (this one point only, on the way out)
โ Settings > General > Speed switched off? โ response untouched
โ not a GET/HEAD, or an RSC/prefetch request, or carrying a
session/member/module-declared cookie? โ response untouched
โ otherwise add:
Cache-Control: public, max-age=0, s-maxage=<ttl>,
stale-while-revalidate=<ttl>
Security headers. In production every response leaving the proxy is wrapped in withSecurity(), which applies the CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy and HSTS. That includes the site-status gate's protected-role bypass: an admin previewing a coming-soon or maintenance site takes an early return out of the proxy, and until 2026-07-23 that one path returned a bare NextResponse.next() and therefore no headers at all. Any new early return added to proxy.ts must go through withSecurity() for the same reason. Framing is allowed same-origin only (frame-ancestors 'self', X-Frame-Options: SAMEORIGIN, since 2026-08-09): the site may embed its own pages - a module's "learn more" modal iframes the chrome-free product details view - while framing by other origins stays forbidden. Note the headers are skipped entirely in development, so anything relying on them (or broken by them, like an embedded iframe) only shows its true behaviour on a deployed site.
Page caching. Off by default. When a site owner turns it on (Settings โ General โ Speed), withPageCache() adds a shared-cache Cache-Control so a CDN - Cloudflare, the host's own, whatever sits in front - can answer repeat visits without waking a function or touching the database. It runs at exactly one place: the final pass-through at the bottom of proxy(), after every gate above has had its say, so an admin page, an API call, a redirect, a coming-soon page and a members-only block are never given a cache header at all.
The decision itself is pageCacheControl() in lib/cache/page-cache.ts, kept pure so it can be tested without a request. Its governing rule is that it is additive: it either returns a shareable header or it returns nothing and the response is left completely alone. It never writes a no-store of its own. The proxy cannot tell a per-visitor page from a prerendered one, and a blanket no-store on the "not shareable" path would have quietly switched off the caching a statically rendered page already had - a speed feature that made the site slower.
A response is left alone for: anything that is not a GET or HEAD; a Range request; a URL whose query carries an access key (key, token, access_token, api_key, apikey - carriesAccessKey()), because a feed opened with a secret is read by one machine on a timetable and a shared copy only delays it: Google Merchant Center's daily fetch of the shopping feed was being handed the copy cached by its previous fetch, a day out of date; a request carrying cactus_session, cactus_member_session, or any cookie an installed module declared through the manifest's cacheBypassCookies (collected by scripts/generate-module-cache-cookies.mjs into the gitignored lib/modules/cache-cookies.ts, exactly like the CSP origins below); and - the one that is not obvious - any request carrying an RSC, Next-Router-Prefetch or Next-Router-State-Tree header. A React Server Component payload shares its URL with the HTML document and is distinguished only by request headers, which Next.js correctly advertises in Vary. Cloudflare honours Vary for Accept-Encoding and nothing else, so a shared cache permitted to hold both will eventually hand a browser the flight data or a router the document, and both render as a broken site. Only the plain document is ever made cacheable; the payloads carry no Cache-Control from us and so never enter a cache at all.
The header has to be set from the proxy because a Server Component cannot set response headers and the route segment config that can (revalidate) is a build-time constant, which could never be driven by a switch in the database.
Two headers are set, and the second is the one that matters in production. On a self-hosted server the proxy's Cache-Control wins, because Next.js writes its own only when one is not already present (sendRenderResult, next/dist/server/send-payload.js) and the router server applies proxy response headers before the render runs. Vercel does not use that path and overwrites it, which is why the first version of this feature did nothing at all on a live site while the setting sat there reading as on. Proxy-set headers otherwise survive there perfectly well - the site's CSP and HSTS arrive intact - so the problem was only ever that one header name.
The proxy therefore also sets CDN-Cache-Control (RFC 9213), which Next.js has no opinion about and so never rewrites, and which Cloudflare reads in preference to Cache-Control (precedence: Cloudflare-CDN-Cache-Control > CDN-Cache-Control > Cache-Control). That gives the split the feature wanted anyway: shared caches keep a copy, while the visitor's own browser is still told no-store by Next and keeps none.
Its value is public, max-age=<window>, stale-while-revalidate=<window> (since 2026-09-13). The window is spelled max-age on purpose: browsers ignore this header, so max-age in it already means "how long the CDN may keep it", while s-maxage implies proxy-revalidate and forbids a shared cache from ever serving a stale copy. Cloudflare's own documentation says not to pair the two. The first version of this header was public, s-maxage=<window> only, so every visitor who arrived after a window closed waited for a full re-render - one to three seconds on a product page against under two hundred milliseconds for a cached copy. With stale-while-revalidate that visitor is answered from the old copy at once and Cloudflare rebuilds it in the background (cf-cache-status: UPDATING); Vercel's edge behaves the same when it is the one reading the header. The earlier worry that Cloudflare answers BYPASS for any directive beyond public and s-maxage came from Cloudflare's RFC 7234 section 3.2 rule, which concerns responses to requests carrying an Authorization header - a public page request never does. If a live site ever shows cf-cache-status: BYPASS on a cacheable page after this change, that reading was wrong and sharedCacheDirectives() in lib/cache/page-cache.ts is the line to put back. Module API responses (below) carry the same value.
Two windows, not one. resolveCacheWindow() picks between the owner's ordinary window and a second, longer one (pageCacheLongTtl, default 0 = "there isn't one"). The long window applies to any address carrying a query string and to the machine-read files - /robots.txt, /sitemap*.xml, */feed.xml, /llms.txt, /llms-full.txt and any .md address - which between them are most of a large site's crawl surface and the part of it that changes least. On a shop with options that is not a small distinction: a catalogue of 731 pages published 19,679 sitemap addresses, 18,948 of them option permutations, and each one is a separate entry in every cache in front of the site and a separate full dynamic render the moment its window closes. It can never make anything less fresh than the ordinary window, because the longer of the two wins. cdnCacheControlForWindow() exists for this and must be used in preference to cdnCacheControl() on a resolved window: the latter re-normalises its argument against the ordinary option list, which would silently reset a week to five minutes.
How long Vercel's own edge keeps a copy. Vercel-CDN-Cache-Control outranks CDN-Cache-Control at Vercel and is stripped before the response leaves it, so it decides where in the chain the copy actually sits. On a site with a purgeable cache downstream (behindCloudflare, or purge credentials set) the copy has to sit downstream, or a publish drops Cloudflare's copy only for Cloudflare to refill from Vercel's stale one. That was first written as a flat s-maxage=0, which moved the copy correctly and meant Vercel held nothing: every miss at Cloudflare - a crawler arriving at a cold location, any address whose window had just closed - re-rendered the page in a function, which on a large catalogue is most of the bill. vercelEdgeTtl (default 60s) lets it keep a short copy underneath to absorb exactly that, clamped to the downstream window so the upstream copy can never outlive it. The cost is bounded and stated in the setting: a publish's purge takes up to that long to be visible everywhere. 0 restores the instant-purge behaviour.
Module API responses. The page cache never reaches one: every module route is served through the dispatcher at app/api/m/[module]/[...path] and answers with a Response the proxy has already finished with. That left the busiest thing on a storefront uncached - a product page whose HTML a CDN answers still fires its islands (reviews, related products, the variation selector) at the origin on every view, and once the pages are cached those calls are most of what wakes a function at all. A route opts in by exporting publicCacheTtl, and the generated dispatcher passes its response through applyModuleApiCache() in lib/cache/module-api-cache.ts, which applies the same rules in one place: GET/HEAD only, 200 only, never over a Cache-Control the route set itself, never for a request carrying a bypass cookie from that same shared list, only when the owner's switch is on, capped at an hour. It sets CDN-Cache-Control for the same reason the proxy does - Vercel rewrites Cache-Control on a module route too, which is why a route that thought it was asking for s-maxage=15 arrived on the wire as a bare public.
Clearing cached pages. lib/cache/cdn-purge.ts calls Cloudflare's purge API beside every existing revalidatePath() in the page routes, and does a full purge when the cache is switched off or its window changes - otherwise copies already out there keep being served for up to an hour after the owner unticked the box. It is best-effort by design: a silent no-op without CLOUDFLARE_ZONE_ID and a purge token, and it never throws. Publishing a page must not fail because a cache API had a bad minute, and a missed purge costs at most the chosen window.
A CDN purge is only ever half of a clear, and it is the second half. app/(public)/[slug]/page.tsx renders with revalidate = false, so Next holds a top-level page's HTML indefinitely and nothing but revalidatePath() drops it. Purge Cloudflare on its own and the visible result is nothing at all: Cloudflare discards its copy, asks the origin, is handed back the same stale HTML, and caches it for another full window - which reads as "the purge button does not work", for exactly as long as the chosen window. The two purge everything call sites (the manual button in app/api/admin/cache/purge/route.ts, and a page-cache setting change in app/api/admin/config/route.ts) therefore call revalidatePath('/', 'layout') first. The per-path sites always did. Order is not cosmetic: purge before revalidate leaves a window in which the CDN refills from an origin that is still stale.
Client IP behind a proxy. clientIpFromHeaders() in lib/auth/rate-limit.ts takes the last x-forwarded-for hop, deliberately: the leftmost entry is whatever the caller typed, and trusting it let a rotating header walk through every per-IP limit. Behind Cloudflare that last hop is Cloudflare's edge, which would put every visitor from one Cloudflare location in a single rate-limit bucket - one person fumbling a password locking out a region. It therefore prefers cf-connecting-ip, but only when the owner has set SiteConfig.behindCloudflare, because that header is just as forgeable on a site that is not actually proxied and trusting it unconditionally would reopen the exact hole the last-hop rule closed. Even with the setting on, it is believed only when the connecting hop is one of Cloudflare's published edge ranges (lib/auth/cloudflare-ips.ts): a site behind Cloudflare is usually still reachable at its platform address (*.vercel.app), and a request sent there directly could otherwise carry any cf-connecting-ip it liked. Those ranges are a static list; if Cloudflare adds one and it is missing here, visitors through that edge share a bucket (over-limiting), never the other way round.
Module-declared CSP origins. A module whose front-end loads a third-party SDK - a payment provider drawing its card fields in its own iframes - cannot work under that policy, and neither of the old ways round it was acceptable: naming the vendor's hostnames in a committed core file is exactly the module leak core forbids, and making the site owner fill in CSP_EXTRA_ORIGINS by hand means a checkout that does not take cards until somebody reads the manual. So a module declares what it needs in its manifest's cspOrigins, scripts/generate-module-csp.mjs collects every installed module's entries into the gitignored lib/modules/csp-origins.ts, and buildCsp() unions them into the matching directive. Core learns no hostnames, the widening lasts exactly as long as the module is installed, and it is bounded on purpose: script, style, img, font, connect, frame, media and form-action, https origins only, at most one leading *. label, validated in both the manifest schema and the generator. Policy keywords are rejected as origins rather than passed through, and frame-ancestors and base-uri - who may frame this site, and what every relative URL on it resolves against - cannot be reached from a manifest at all. form-action was withheld too at first, on the same reasoning, and had to be let in at 0.5.1239: 3D Secure POSTs its challenge form to the card network's authentication server, and the payment SDK runs that POST inside an iframe it creates in the site's own document, which inherits the site's own policy. Left at 'self' the shopper gets a blank white modal at the exact moment their bank asks them to confirm, and no on-page card payment can complete. It is a narrower grant than the two still withheld - it widens only where a form on this site may submit, for as long as the module is installed, and confers no ability to read anything. frame-src and form-action are the two directives that accept the bare * (frame-src from 0.5.1242 - the challenge is POSTed into an iframe pointing at the same host, so blocking either half leaves the same blank modal), and that concession was forced by the same feature one release later: naming cardinalcommerce.com fixed the first live failure and secure2.arcot.com produced the second, because the challenge is served by whichever authentication provider the shopper's bank uses and that set has no end to it. An allowlist there does not yield a working checkout, it yields one that fails silently for whichever customers bank in the wrong place. * is rejected on every other directive. CSP_EXTRA_ORIGINS stays for the other case: a service on a subdomain of the owner's own site, which no module author could know at authoring time.
The one hostname core allows for the CDN's sake. script-src also names https://static.cloudflareinsights.com, and connect-src names https://cloudflareinsights.com (added 2026-08-25). Neither belongs to a module and nothing in the codebase asks for either: when a site is proxied through Cloudflare with Web Analytics switched on, Cloudflare edits the beacon tag into the HTML on its way past, downstream of the app, where nothing here can decline it. The choice was therefore between a blocked script and a red console error on every page of every Cloudflare-fronted install, or asking each owner to type the two addresses into CSP_EXTRA_ORIGINS before their site looked clean. An install that is not behind Cloudflare never receives the tag, so the entries sit unused. Note what this does not fix: fonts blocked inside a payment SDK's own iframe are blocked by that vendor's policy, not by this one, and no widening here can reach them.
Speed Insights. app/layout.tsx is the root layout for every route - public pages, the admin, status and error pages alike - and renders Vercel's <SpeedInsights /> there. It is gated on SiteConfig.speedInsightsEnabled (Settings โ General, default on) via isSpeedInsightsEnabled() in lib/config/site.ts; switched off, the script is never sent rather than sent and suppressed. That reader follows the same pattern as getAdminPathCached()/getSiteStatusCached() beside it - a 5-second in-memory TTL and a best-effort read - for the same reason: a value consulted on every request in the root layout should not cost a query each time, and a database blip there is a bare 500 for whatever the visitor asked for rather than an error page. A failed read falls back to on, which is what every install did before the switch existed. invalidateSiteConfigCache() (already called by the config PATCH) clears it, so the switch takes effect on the next request rather than up to five seconds later.
The favicon is a route, not a file (core 0.5.1150). app/layout.tsx emits <link rel="icon"> from the admin's branding, which covers every response that has a document head - and nothing else. /sitemap.xml, /robots.txt, a JSON API response and any downloaded file have no head, so the browser falls back to asking the origin for /favicon.ico directly, where it used to find the Cactus icon shipped in /public. A beforeFiles rewrite in next.config.ts now sends /favicon.ico to app/api/branding/favicon/route.ts, which resolves resolveBranding() and answers with the admin-set favicon (or 307s to /cactus-favicon.ico, where the bundled default now lives, when no custom icon is set). beforeFiles is what makes it deterministic: that stage runs ahead of the static file handler, so there is no public-versus-route precedence to reason about. The proxy's matcher already excludes .ico, and /api/branding/ is in ALWAYS_PASS so the icon still answers during setup and while the site is closed - exactly as the static file it replaced did.
The route serves the bytes, and the <link> tags point at it (core 0.5.1282). It used to 307 to the media host the icon lives on, and the root layout's <link rel="icon"> named that host directly - on the reasoning that proxying a CDN-hosted icon buys a second hop and nothing else. That reasoning missed how browsers fetch a tab icon: last, at the lowest priority there is, and the result then cached against the page url rather than the icon's. Pointing them at a third-party host put the icon behind every product image on that same host, and one dropped fetch left that one page with a blank tab until the browser's favicon cache aged out - which reads to an owner as "some pages have no favicon, and reloading fixes it". Served from the site's own origin it rides the connection the document already has open, and the CDN in front of the route absorbs the cost. ?scheme=dark answers with the dark-mode icon, so the two colour-scheme-scoped <link> tags can both name an address on this origin. Conditional requests are forwarded upstream so a browser that already holds the icon still gets a 304, and any upstream failure - slow host, rotted url, 5-second timeout - falls back to the bundled Cactus icon rather than a broken tab.
The same route now serves the whole icon set (core 0.5.1294). 0.5.1282 fixed /favicon.ico and left the other three icons naming the media host directly: app/layout.tsx emitted apple: b.appleTouchUrl and app/manifest.ts emitted b.icon192Url/b.icon512Url. That leaves the identical exposure with a different audience - WebKit leans on the Apple touch icon for tabs, bookmarks, the Start Page and the home screen. Measured on the live Deskwell site, a category page issues 49 requests to media.deskwell.co.uk against the homepage's 7 (the raw HTML names 983 against 34, but most are srcset candidates). Stated plainly: the original "no favicon on some pages" report was diagnosed at core 0.5.1290 as page weight, not icon addressing - that page was 7.2 MB with a load at 15.2 s, and a browser does not choose a tab icon while it is still parsing - and on-demand paging cut it to 706 KB and 1.27 s. This change is not a proven fix for the Safari sighting that followed. It is here because the exposure is real on its own terms: three of the four icons were cross-origin, and a branded site asked for /apple-touch-icon.png still answered with Cactus's. app/api/branding/favicon/route.ts now takes an ?icon= variant (favicon, favicon-dark, apple-touch, icon-192, icon-512; ?scheme=dark kept as an alias for the second), each with its own branding field, bundled fall-back and default content type, and next.config.ts adds four beforeFiles rewrites: /apple-touch-icon.png, /apple-touch-icon-precomposed.png (Safari probes both at the root when a document offers no icon), /web-app-manifest-192x192.png and /web-app-manifest-512x512.png. The layout and the manifest emit those addresses unconditionally now, custom icon or not, since the route resolves the branding itself. The bundled Cactus files moved to public/cactus-apple-touch-icon.png, public/cactus-web-app-manifest-192x192.png and public/cactus-web-app-manifest-512x512.png for the reason cactus-favicon.ico already sits there: a fall-back that redirected to the path the rewrite listens on would meet itself coming the other way. The proxy needs no new ALWAYS_PASS entry - its matcher already excludes any path ending .png, so these never reach it, exactly as .ico does not.
The actual cause of the Safari report, found after two releases aimed at the wrong thing. Neither addressing nor page weight: the root layout emitted, for a branded site, a single <link rel="icon" href="/favicon.ico"> with no type and no sizes - and /favicon.ico answers with PNG bytes, because generate-icons renders the favicon as a 96x96 PNG. An address ending .ico returning image/png is sniffed happily by Chrome and refused by WebKit's icon loader, so Safari drew a blank tab on every page of the site - including an 80KB page with no images, which is what finally killed the weight theory. An install with no custom favicon was never affected, because that branch has always emitted a typed pair (image/svg+xml plus 96x96 image/png). Turning branding on made the icon declaration strictly worse. The fix, core 0.5.1298: generate-icons now renders 16 and 32 as well (SIZES gains favicon16/favicon32), stored on two new nullable SiteConfig columns favicon16MediaId/favicon32MediaId (init SQL edited in place plus prisma/core-reconcile/029_favicon_16_32.sql); the route gains icon-16/icon-32 variants behind beforeFiles rewrites for /favicon-16x16.png and /favicon-32x32.png, with public/cactus-favicon-16x16.png and -32x32.png as bundled fall-backs; and app/layout.tsx pushes typed, sized PNG candidates alongside the .ico entry. Two deliberate choices there: the 16/32 links are emitted only when the admin has actually generated them, since falling back to the bundled Cactus pair would advertise somebody else's logo on a branded site, and the existing 192 is offered as a typed candidate unconditionally so an install that has not re-generated still has something WebKit accepts today. When a dark favicon is configured the PNG candidates are scoped to (prefers-color-scheme: light), leaving the colour-scheme-scoped .ico pair to own the dark case. Gate: schema change, so npm run test:backup-roundtrip was run against real throwaway databases - 3 passed, not skipped. A second pass over the same chain closed four more: /favicon.ico now answers with a real ICO container when the 16/32 renditions exist (lib/config/favicon-ico.ts wraps the PNGs - pure buffer work, strong ETag, self-handled 304; a hand-uploaded genuine .ico is served as-is, never wrapped), which also covers every headless context that probes the root with no document head to offer alternatives; resolveBranding carries the favicon media rows' mimeType so the layout labels the .ico links with what the address actually answers instead of nothing; the proxy path forwards Content-Length only when the upstream sent no content-encoding (fetch decompresses transparently, so the compressed length truncated the decompressed stream); and failure-driven fallback redirects carry max-age=60 rather than the hour that let one slow upstream fetch pin the CDN on the Cactus icon sitewide.
The email logo is a route too (core 0.5.1264). Mail clients are not browsers: Gmail discards an SVG image outright and Outlook's Word-based renderer has never drawn a WebP, which between them covers most of what a site logo actually is, since a designer supplies a vector and the media optimiser turns nearly everything else into WebP. The result is a hole at the top of every transactional email while the site itself looks perfect. So getSiteEmailContext() no longer hands templates the logo's own URL. emailLogoUrl() in lib/email/logo.ts passes PNG, JPEG and GIF straight through - already email-safe, and the media provider's CDN serves them better than core ever will - and points anything else at app/api/branding/email-logo/route.ts?v=<mediaId>, which downloads the original, rasterises it with sharp to a 640px-wide PNG and answers with a year-long immutable cache. The v parameter is the logo's media id: it makes a new logo a new address, so nothing holding the old picture keeps serving it, and a request naming anything else is 307'd to the canonical address rather than rendered, so query-string noise cannot mint cache keys. An SVG is rasterised at a density calculated to land on 640px rather than at the default 72dpi, so a small drawing is rendered large instead of being blown up afterwards. A warm instance memoises the last render. If the download or the render fails, the route redirects to the original media URL: no worse than before for a client that could not draw it, and correct for one that could. /api/branding/ is already in ALWAYS_PASS, which earns its keep here - mail goes out from sites that are closed or in maintenance, and the logo in it has to load anyway.
Why proxy.ts instead of middleware.ts? Next.js 16 moved the request-interception layer from the Edge runtime to Node.js and renamed the file. Running on Node.js means Prisma works directly - no edge-compatible ORM, no edge Config only as a fallback. The admin path and site status checks can use real database reads.
The admin path is a secret URL prefix chosen during setup. It's stored in SiteConfig.adminPath and mirrored to Vercel Edge Config whenever it changes (via the Vercel REST API). proxy.ts reads it from Edge Config first (fast, no database round-trip), falling back to a Prisma read cached briefly in memory if the Edge Config write credentials are absent. Same pattern for site status.
That fallback read is deliberately failure-tolerant. The in-memory cache only lives five seconds, so a freshly started instance always has to go to the database, and a throw inside proxy.ts is not a friendly error page - it is a bare 500 on whatever the visitor asked for. getAdminPathCached() and getSiteStatusCached() therefore return the last value they successfully read when a refresh fails, rather than letting the error escape. A value a few seconds stale is harmless here: both only change when an admin edits them.
The setup gate tells "no site here yet" apart from "the database is having a moment". The gate asks the database whether setup is finished, and until 2026-09-03 any failure to answer counted as "not finished" - so a database that was briefly unreachable turned a live site, shop and all, into the first-run setup wizard for every visitor and every crawler that arrived while it lasted, and the admin's own Settings page showed "Failed to load config" just before it. A failed read is now classified. Two cases still mean genuinely unconfigured and still reach the wizard: no DATABASE_URL at all, and a connection that works but has no schema behind it yet (Prisma P2021/P2022), which is the state a fresh install sits in between the wizard writing the connection string and the migration running on the next deploy. Everything else - refused, timed out, pool exhausted, server restarted - is an outage on a site that already exists, and answers 503 with a Retry-After and a short "Back in a moment" page instead. /setup and /api/setup/* are exempt from the 503 so a fresh install whose database is still coming up can always reach the wizard. The same reasoning applies one layer in: the homepage's own SiteConfig read (app/(public)/page.tsx) no longer swallows its errors either, because a failed read there sent the root URL - the address every shared link and every crawler starts from - to the wizard as well. proxy.test.ts covers all six outcomes.
The admin section also has a render error boundary (app/cactus-admin/error.tsx), covering the sign-in page as well as the rest of admin. It replaces the framework's default "Internal Server Error" screen with a card explaining the request did not load and a Try again button that re-runs the render. Errors thrown by the admin layout itself go to the parent boundary rather than this one, so the layout guards its own reads inline instead: the session read falls back to a redirect to the (resilient) login page, and the config, module-list, unread-count and permission reads each fall back to an empty value the shell already tolerates, so a passing blip loses a corner of the chrome rather than the whole frame.
Before database provisioning, the wizard connects to Vercel and attaches a domain to the project:
-
Add/attach domain -
POST /v10/projects/{projectId}/domainsadds a new domain, or an already-owned account domain can be attached directly. Either way the response is followed byGET /v6/domains/{domain}/configto read Vercel'smisconfiguredflag and the recommended DNS record. -
DNS confirmation gate - if the domain comes back
misconfigured, the wizard shows the required DNS record and disables "Configure project โ" until it resolves. It re-checks automatically every 10 seconds (plus a manual "Check now" button) and unlocks the button the moment Vercel reports the domain as configured. This stops the user being carried forward to a custom domain that doesn't resolve yet. - Domains already attached to the project use their
verifiedflag from the initial project listing to decide whether the gate applies. -
Redirect to the custom domain - once the combined bootstrap redeploy (below) picks up
SITE_URL, the browser needs to move from the Vercel-assigned URL to the real domain before Step 3 (WebAuthn's rpId is derived fromSITE_URL, so passkey registration fails across a mismatched origin). The check (checkSiteUrlAndRedirectIfNeeded) runs at the point the wizard is about to advance from Step 2 to Step 3 - before Step 3 ever renders, not reactively after - so there's no flash of Step 3 on the wrong domain. It compares the current origin against a freshGET /api/setup/env-check(siteUrlfield, read server-side fromprocess.env.SITE_URLon every request, never the client's build-time-inlinedNEXT_PUBLIC_SITE_URL, which would still hold the pre-redeploy value in an already-open tab). If it redirects, it appends?autocontinue=1(andexistingData=1if that flow was in progress) so the reloaded wizard on the new domain skips straight past Step 2's "Database connected" click-through and lands on Step 3 immediately, rather than making the user click Continue again.
When DATABASE_URL is absent at setup time and NEON_API_KEY is configured, the setup wizard can provision a Postgres database automatically:
-
Neon API call -
POST https://console.neon.tech/api/v2/projectscreates a new Neon project. The response includes both a direct and a pooled connection string. Cactus uses the pooled one (connection_parameters.pooler_host) to satisfy the pooling requirement. -
Vercel API write -
POST https://api.vercel.com/v10/projects/{projectId}/envwritesDATABASE_URL(pooled) andNEON_PROJECT_ID(for idempotency) as project environment variables. -
Triggered redeploy - the wizard explicitly triggers a new production deployment via the Vercel API (a redeploy of the latest production deployment, passing
teamIdwhen the project is team-owned). During that build, the existingbuildscript (node scripts/prebuild.mjs && node scripts/next-build.mjs, whose database branch runsprisma migrate deployโreconcile-core-schema.mjsโrun-module-migrations.mjsโflush-pooled-plans.mjs) applies the full schema. If the trigger fails, the failure is surfaced in the wizard with a "Retry redeploy" button and a pointer to the Vercel dashboard - it is never silently swallowed. -
Readiness poll - the wizard polls
/api/healthevery 5 seconds. Once the database is reachable (redeploy complete, schema applied), the wizard advances to the next step. It also streams the deployment's state and build log while waiting; a deployment that ends in ERROR or CANCELED is shown as failed with its log and a retry, not left spinning.
Migrations are never triggered from the wizard. Provisioning only creates the Neon project and writes the env var. The schema reaches the database exactly as it always does: via the build script, run by Vercel during the triggered deployment. The wizard cannot continue in the same page load; the redeploy is the mechanism, not a side-effect.
Idempotency and self-healing - the provisioning action checks for an existing Neon project named {VERCEL_PROJECT_NAME}-{VERCEL_PROJECT_ID} (falling back to cactus-{VERCEL_PROJECT_ID} if the Vercel project name can't be looked up) before creating a new one, and env-var writes are upserts, so a stale DATABASE_URL from an earlier attempt is overwritten rather than turning the request into a no-op. When the wizard loads and finds DATABASE_URL already written to the Vercel project but not yet in the runtime (the "provisioned, awaiting redeploy" state), it calls the ensure-redeploy action: if a production deployment is already in flight it attaches to that one, otherwise it triggers a fresh redeploy - so a reload after a failed or errored deploy recovers on its own instead of waiting forever. Double-clicks and page refreshes are safe.
-
Passkey-first: WebAuthn registration and authentication via
@simplewebauthn/server. The relying party ID is derived fromSITE_URLin production,localhostin development. Credentials are stored in thePasskeytable (public key, counter, transports). User verification is required on both registration and authentication (biometric or PIN, not mere presence). The admin login page fires the WebAuthn prompt automatically on page load (skipped when a recovery token is in the URL); the fallback options (retry passkey, password, authenticator app, lost access) only appear after the automatic attempt fails, is cancelled, or the user clicks "Use another way to sign in". A cancelled automatic attempt (NotAllowedError, e.g. Safari refusing WebAuthn without a user gesture) reveals the fallbacks silently, without an error banner. -
Enrolling a passkey always needs an existing session.
POST /api/auth/passkey/register-optionsand/register-verifyaccept auserIdin the body only while the first-run setup window is open (lib/auth/setup-window.ts:setupCompletedfalse, or no users exist - the same test/api/setup/create-adminapplies). At any other time the target account is derived from the session cookie and a bodyuserIdis ignored;register-verifyadditionally refuses a challenge minted for anyone but the signed-in user.POST /api/auth/passkey/authenticate-optionsreturns{ noPasskeys: true }without auserIdfor an account that has none - it hands out no internal identifier to an unauthenticated caller. There is no "register a passkey and sign in" route: an account with no passkey signs in by password + one-time code, by authenticator app, or via a recovery link, and adds a passkey afterwards from Account โ Security. (The oldregister-loginroute did none of this and was a full account-takeover path for any staff account that had never enrolled a passkey.) -
Authenticator app (TOTP): equal-strength alternative to a passkey, not stacked on top of it. RFC 6238 via the
otpauthlibrary, QR rendering viaqrcode. The secret is stored onUser.totpSecretEncrypted(AES-256-GCM vialib/crypto/secrets.ts, sameENCRYPTION_KEYpattern asGithubAppConnection), unset untilUser.totpVerifiedAtis set by a successful verify. Routes:POST /api/auth/totp/setup-optionsand/setup-verify(dual-mode -userIdin body during setup wizard, session cookie from account settings; theuserIdbody param is only honoured while setup is genuinely incomplete, both routes reject it oncesetupCompletedis true),POST /api/auth/totp/verify(login step, email + code, rate-limited undertotp_verify;setup-verifyshares the same rate limit).User.totpLastSteprecords the last accepted 30s time-step so a code can't be replayed within its validity window.GET/DELETE /api/account/totp.DELETErefuses to disable it if the account has no password and no passkeys (would lock the admin out). -
Sessions: Database-backed (not JWTs). A session token is hashed with
SESSION_SECRETbefore storage. Suspending a user invalidates their session immediately. Admin sessions end after 24 hours without use, not 24 hours after sign-in: while somebody keeps clicking or typing in the admin, the admin shell tells the server so (POST /api/auth/session, at most once every five minutes) and the clock restarts, along with the cookie's own expiry. Only a person counts - the notification poll and other background requests an open tab makes by itself never move the clock, so a forgotten tab still signs itself out after a day. The timings live inlib/auth/session-timing.ts. -
Sign-out in an open tab: the admin shell knows at render time how long the session has left, and works out when it runs out against the browser's own clock, rechecked whenever the tab is brought back into focus (long timers are unreliable in background tabs and stop altogether while a machine is asleep). Several tabs share one session, so a tab that has sat untouched while another was busy can think it is further along than it is - before warning, and again before signing anyone out, it asks the server (
GET /api/auth/session, a read that does not itself count as use) and carries on quietly if the session has more time. Two minutes before the end a small "Still there?" banner appears in the corner with a Stay signed in button; any click or key press anywhere does the same job. At the end the tab goes to the login page, which explains that the session ended. The warning is deliberately a banner rather than a dialog that blocks the page, so anything half-finished can still be saved first. -
Password + OTP fallback (when email is configured): bcrypt, Pwned Passwords k-anonymity check on registration, mandatory 6-digit email OTP as second factor. When the user has enrolled a phone number (
User.smsOtpPhoneEncrypted, AES-256-GCM) and an active module contributes a configured SMS provider (manifestsmsProvidersfield โ generatedlib/modules/sms-providers.ts, consumed vialib/auth/sms.ts), the same OTP is delivered by text message instead of email, falling back to email automatically if the text fails or the provider disappears. Verification is unchanged - both channels share theEmailChallengerecord. Members get the equivalent via anSMSTwoFactorMethodonMemberTwoFactor(phone inphoneEncrypted), which takes priority over other configured methods when deliverable, shares the member email-challenge store, and has the same silent email fallback. -
Trust this browser: a
TrustedDevicecookie skips the OTP step for a configurable number of days. - Recovery: offline single-use recovery code (generated at setup), or email link (30-minute expiry). Both land on the login page's recovery UI.
-
GET /api/auth/configis the one pre-auth payload the login screen reads, and it is deliberately thin:emailConfigured,turnstileConfigured,turnstileSiteKey. It used to includeneonProjectIdas well, purely to deep-link the lost-passkey help at the right Neon project; that is infrastructure metadata handed to anyone who can reach the login page, so it was removed on 2026-07-23 and the help links to the plain console instead. Treat this route as public output when adding to it.
proxy.ts validates the session on every admin request and the admin layout validates it again; neither checks a permission. Authorisation is per screen, and there are three layers, each of which does a different job:
-
The sidebar (
lib/nav/admin-menu.ts) decides what to show.CORE_NAV_ITEMS[].defaultPermissionsis an any-of list per item, and a site owner's saved customisation can override visibility per item (default,everyone,admin,roles,hidden). This is presentation only - theeveryonemode shows a link, it grants nothing. -
The screen decides who may open it. Either the page checks a permission itself, or the section carries a
layout.tsxcallingdenyUnlessAny(lib/permissions/section-gate.tsx), which covers its nested routes too -pages/,media/,menus/,layouts/,modules/,appearance/andconfig/privacy-generator/work this way. This layer is the one that was missing until 2026-09-07: the links were hidden but the screens themselves rendered for any signed-in staff account that typed the address, and Pages and Media are server components, so they queried and rendered the data before any API was involved. -
The API decides who may act. Every
/api/admin/**route re-checks, and always did - which is why the hole leaked screens and reads rather than write access.
Settings is the exception, deliberately. /config is not a config.manage-only screen: a module contributes a settings tab under its own permission (manifest.settingsTabs[].permission), so the page's gate is "may you see any tab at all" and each tab is gated again on its own key - in page.tsx for what it resolves server-side, and in ConfigPageClient via canManageConfig, which controls the six core tabs, the page-level Save button, the backup/restore/reset cards and the four config.manage fetches the page would otherwise fire (and be refused). Email and GDPR each have a second key - emails.templates opens the email Templates sub-tab alone, members.gdpr the members half of GDPR & Legal alone. The Settings bar is sticky (.settings-sticky in app/globals.css): title, Save button and the tab strip stay on screen while the tab scrolls under them. Core's tabs drive the one Save button, which now covers the credentials typed into that tab's cards too (the per-card "Save credentials" buttons are gone - handleSave writes /api/admin/env first, then /api/admin/config). Module tabs put theirs in the same bar through SettingsHeaderActions (components/admin/SettingsHeaderActions.tsx, a portal into .settings-header-slot). The long core tabs are split into sub-tabs held in ?sub=: General (Site, Backup & restore, Danger zone), GDPR & Legal (Legal pages & retention, Cookie banner, Members' data), Integrations (GitHub, Vercel, Protection & monitoring, Database), Email (Delivery, Templates) and Schedules (one per owner, in ScheduledJobsClient). Each sub-tab lists the deep-link anchors it renders (SUBS_BY_TAB/locateAnchor in ConfigPageClient.tsx), so a command-palette link to #general-backup opens the right sub-tab before it scrolls. Bodies lay out in two columns on a desktop and one on a phone (.settings-cols, .settings-masonry).
lib/permissions/admin-section-gates.test.ts walks every page.tsx under app/cactus-admin and fails on one that neither checks a permission nor sits under a denyUnlessAny layout. Genuinely open screens (the dashboard, login, your own account, the Inbox host, the Settings door, the module router) are listed in OPEN_PAGES with the reason. It deliberately does not accept the admin root layout as cover - that layout resolves permissions to build the sidebar, and counting it would pass the whole tree.
The Members system is a parallel, independent account system for site visitors - entirely separate from the admin User model above. A Member never has admin permissions and a User never appears in the member directory; the two tables, sessions, and cookies never mix. See Members for the site-owner-facing explanation.
All settings live in a single SiteConfig.membersConfig JSON column (lib/members/config.ts, MembersConfigSchema, Zod-validated with safe defaults on parse failure - a corrupted column never takes the site down). getMembersConfigCached() gives proxy.ts a 5-second in-memory cache, matching the existing getAdminPathCached pattern. The member area's own URL prefix (default account) is set separately, via the MEMBER_AREA_PATH env var (lib/members/paths.ts), since - like the admin path - changing it needs a redeploy.
lib/members/registration.ts and lib/members/tokens.ts handle username/email validation, invite-token validation (invite-only mode), and email verification tokens (sha256(token), single-use, short-lived - see the token-hashing convention below). deriveInitialStatus/deriveActivatedStatus compute a member's status (PENDING_VERIFICATION, PENDING_APPROVAL, ACTIVE, etc.) from the registration mode and verification requirement, so the same deterministic status is reachable from multiple code paths (a genuine registration, and the enumeration-safe branch below) without ever hardcoding it.
Enumeration safety: every member-facing auth endpoint (registration, verification resend, magic link, username availability) returns the same response shape regardless of whether an account or email actually exists, so a visitor can't use response differences to discover who is registered.
lib/members/session.ts mirrors the admin session module but is entirely separate: its own table (MemberSession), its own cookie (cactus_member_session), its own trusted-browser cookie (cactus_member_trusted). Session validation only ever succeeds for ACTIVE members; a suspended, deleted, or not-yet-approved member's session token stops working immediately even if the cookie is still present.
Three sign-in methods, each carrying its own OFF/OPTIONAL/REQUIRED policy in membersConfig.authMethodPolicies (MAGIC_LINK has no REQUIRED state - nothing is enrolled for it): passkeys (lib/members/passkey.ts, mirrors the admin passkey module against Member/MemberPasskey), magic links (lib/members/magic-link.ts, single-use emailed token), and password + mandatory two-factor (a code sent to the verified email address by default, or an enrolled TOTP/SMS method). lib/members/login-methods.ts makes the magic link an account bootstrap only: it is available while neither enabled enrolable method is held, and disappears once an enabled password or passkey exists. lib/members/password-second-factor.ts is the shared selection rule for both halves of password login, including the built-in email fallback. Token hashing follows the existing project convention: long-lived credentials (sessions, trusted browsers) are hashed as sha256(token + SESSION_SECRET); short-lived single-use tokens (verification, magic links, invites) are plain sha256(token).
REQUIRED is enforced rather than advertised. lib/members/auth-policy.ts computes what a member still owes (memberOutstandingAuthSetup); the account layout redirects to /<memberAreaPath>/setup-signin until the list empties, the registration form collects a password up front when PASSWORD is REQUIRED, and removing a last passkey or a password is refused while that method is required. A required password is usable as soon as email-code delivery is available; without email, the member must enrol another verified second factor. Two guards sit on the settings save: all-methods-off is rejected, and switching the magic link off is flagged, because a member who has neither enrolable method still needs it for their first session.
Security-sensitive changes (passkey added/removed, password changed, 2FA changed, new trusted browser) trigger an email via lib/members/security-alerts.ts, independent of whatever notification preferences the member has set for module-driven notifications.
The member-facing account area (/<memberAreaPath>/..., e.g. /account) has five independently-toggleable sections: Profile, Security, Notifications, Activity, Danger Zone, plus /setup-signin, the one page the required-method gate lets through. Its layout gate reads the x-cactus-member-full-path header set by proxy.ts to build an accurate post-login redirect, and the same header tells the required-method gate which page it is on.
Every member gets a public profile at /members/<username>, whose visibility (PUBLIC / MEMBERS_ONLY / HIDDEN) is config-driven, plus an optional member directory. Avatars resolve through lib/members/avatar.ts (resolveEffectiveAvatarChoice) across three sources - an uploaded photo, Gravatar (proxied server-side through /api/members/avatar-proxy/[id] so a member's email is never exposed to a client-side Gravatar request), or a generated initials avatar - respecting the avatarUploadsEnabled/gravatarEnabled config toggles even if a member's stored choice predates a toggle being switched off.
A member's website field goes straight into an href on that public profile, which makes it a stored-XSS surface. It is validated with isHttpUrl() (lib/utils.ts), an allow-list that parses the value and accepts only the http: and https: schemes. Zod's .url() is not a substitute: it accepts javascript:alert(1) perfectly happily, which is exactly how the hole existed. The check runs at both write paths (the member's own PATCH /api/members/profile and an admin's PATCH /api/admin/members/[id]) and again at render, because rows written before the guard existed are still sitting in the database and a write-time check can never clean those up. Any new field that stores an owner- or visitor-supplied address and later emits it into an href or a src should use the same helper on both sides.
Members can request a full data export (assembled server-side, downloadable for 48 hours, lib/members/export.ts) or delete their account (soft-deleted with a config-driven grace period before a cron job purges it, lib/members/deletion.ts). Every email the site sends - member-facing (verification, magic link, security alerts, digests), account and security (login codes, recovery, password changed), and every installed module's - flows through a single template registry (lib/email/registry.ts) that admins customise per-key from Settings โ Email โ Templates, with a merge-tag list, preview, test-send and reset-to-default. Modules contribute their own through the manifest emailTemplates seam (collected by scripts/generate-module-email-templates.mjs into lib/modules/email-templates.ts); a module may only claim keys prefixed with its own name. Every default body - core's and every module's - gets its own subject prepended as an <h3> when the registry is built, so the heading tracks the subject, covers module templates without them each having to remember, and still reaches the editor as plain editable copy. Preview and test-send (previewEmailTemplate) substitute obvious stand-ins for send-time merge values but deliberately leave the site-level ones (siteName, siteUrl, logoUrl, year) to resolve for real, so an admin previewing their own copy is never shown [siteName] where their site's name belongs. An override is decided by what the copy says, not by whether a row exists: a stored subject or body identical to the registry default (emailOverrideValue, and smsOverrideValue for texts) counts as no override at all, and a save posting back the stock wording stores null rather than freezing that email on today's default. Rendering (lib/email/render.ts) applies the override, interpolates, then drops the message into an emailWrapper Layout - a core layout type with its own Puck config and its own email-safe block set (lib/puck/email-config.tsx, lib/email/blocks.ts), chosen per email or defaulted to the highest-priority published wrapper.
Text messages get the same treatment one channel over, deliberately mirrored rather than merged: lib/sms/registry.ts (module-contributed only - core sends no template texts of its own), SmsTemplate rows for the admin's overrides, lib/sms/render.ts for the interpolation (plain text, no escaping, no wrapper) and lib/sms/send.ts for the send, which hands the finished body to whichever active module contributes a configured SMS provider. Modules contribute through the manifest smsTemplates seam, collected by scripts/generate-module-sms-templates.mjs into lib/modules/sms-templates.ts. The admin API lives at /api/admin/sms/templates behind the sms.templates permission; the editor itself belongs to the SMS-provider module (the Twilio settings tab's Templates sub-tab), on the grounds that a site with no way of sending a text has no business being shown a text editor.
Six member-aware Puck blocks (login, register, account link, member gate, trusted-member gate, profile) are defined in lib/puck/config.tsx alongside the rest of the block library, but their actual render logic lives in a separate lib/puck/components/MembersBlocksRsc.tsx - each an async Server Component starting with await connection() to force per-request dynamic rendering (so a gate block always reflects the current visitor's session, never a cached render). This mirrors the existing moduleComponents/moduleRscComponents editor/RSC split used for module-contributed blocks, keeping session-dependent code out of the shared editor-bundle file.
Modules declare member-related extensions declaratively via an optional memberExtensions manifest field (lib/modules/manifest.ts) - activityTypes, notificationCategories, dataExportPath, and routeTiers. A notification category names the ways it can be delivered (channels: ['EMAIL', 'SMS'], email alone by default) and whether it is required - a required category may have its delivery changed but never switched off entirely, which /api/members/notifications enforces by refusing to disable the last remaining channel. The SMS column only appears where an active module contributes a configured SMS provider (lib/auth/sms.ts). Unlike extensionPoints/settingsTabs, this is pure data (no static component imports, no codegen step): lib/modules/member-extensions.ts reads installed modules' manifests directly, and proxy.ts's site-access gate consults a 5-second-cached getModuleRouteTiersCached() to resolve whether a given path is PUBLIC, MEMBER, or TRUSTED_MEMBER, independent of the site-wide members-only toggle - either trigger can gate a path, so a module can lock one route tier down without the site owner switching on site-wide members-only mode.
Cactus supports ten media providers across two shapes.
Private object storage. The Cloudflare Worker sits between the browser and the bucket, fetching, resizing, and caching the image. The Worker URL is the serving URL for every proxied item regardless of which bucket holds the bytes.
Browser โโโโ Next.js <Image> โโโโโถ Custom loader (lib/media/loader.ts)
โ
โ builds Worker URL: https://worker.example.com/<key>?w=<width>&q=<quality>
โผ
Cloudflare Worker (workers/media-worker/)
โ
โโโ key format: media/<PROVIDER>/<id>.ext
โ (legacy B2 keys: media/<id>.ext - no provider segment)
โโโ resolves provider from key prefix
โโโ fetches from the matching private bucket using stored secrets
โโโ applies Cloudflare Image Resizing (width, quality, format=auto)
โโโ returns with cache headers (1 year, immutable)
These have their own CDN and URL-based transformation systems. The Worker is never involved. The Next.js loader detects a Cloudinary or ImageKit URL and builds that provider's own transformation URL directly. Images go: browser โ provider CDN.
Browser โโโโ Next.js <Image> โโโโโถ Custom loader (lib/media/loader.ts)
โ
โ Cloudinary: inserts /w_N,q_N/ into /upload/ segment
โ ImageKit: appends ?tr=w-N,q-N
โผ
Provider CDN (res.cloudinary.com / ik.imagekit.io)
GET /api/admin/media serves two different jobs from one route. With a folder, q, tag, type, sort, filter or page parameter it is the paginated library listing that backs the media screen and every picker. With ?id=<mediaId> it short-circuits ahead of all of that and returns just that one row, still wrapped in the same { items, total, page, perPage, hasMore } envelope so a caller needs no second response shape; an unknown id comes back as an empty items array rather than a 404. Callers that hold an id and want to render a preview - the OG-image field and the media picker's own selected-item preview - use this form. Until 2026-07-23 the parameter was silently ignored and those callers fell through to the default newest-first listing, so they rendered whatever had been uploaded last: a bug that looks like a caching problem and is not one.
The active provider is SiteConfig.mediaProvider. Changing it in Settings โ General โ Media affects new uploads immediately but does not move existing objects. Media.provider on each row records where that specific item actually lives. A MediaMigrationJob (cursor-based, batch-driven, admin-initiated) converges all rows onto the active provider. The Worker holds credentials for every proxied provider it has ever had configured so it can serve items that have not yet been migrated.
Why not proxy through Vercel? Vercel bills GB-hours of serverless execution. A 10 MB image served through a Next.js route handler on every page view burns real money at scale. The Cloudflare Worker sits outside Vercel's billing, caches resized variants at Cloudflare's edge, and never touches Vercel's function runtime for image bytes.
Three admin actions derive a new set of bytes from an existing Media row, and all three share one persistence path:
| Action | Route (single / bulk) | Geometry | What it does to the pixels |
|---|---|---|---|
| Crop | /api/admin/media/[id]/edit |
inline, clamped to bounds | extracts a rectangle |
| Change ratio |
/api/admin/media/[id]/aspect / bulk-aspect
|
lib/media/aspect-plan.ts |
pads to a target ratio; never trims or stretches |
| Resize |
/api/admin/media/[id]/resize / bulk-resize
|
lib/media/resize-plan.ts |
uniform downscale to fit a box; never enlarges |
All three gate on media.upload, take mode: 'replace' | 'new', and funnel into persistDerivedImage in lib/media/upload.ts. 'new' mints a fresh row; 'replace' rewrites the blob under the existing row, keeping the id (and, where the key is already exact-named, the key and url too) so every reference to the image survives - including references held outside Puck content, which the reference rewriter cannot reach.
The geometry for reshape and resize lives in its own pure, unit-tested module with no sharp or prisma import: the arithmetic is the part that silently ruins images, and it is cheap to pin down. Both cap their output (MAX_ASPECT_PIXELS / MAX_RESIZE_PIXELS, 40 MP) by scaling the whole plan uniformly rather than by trimming.
Both bulk routes run sequentially - each re-encode is CPU-heavy - and report a three-bucket tally (changed / skipped / failed). A skip is a 200 with a plain-English reason, not an error: "already that shape", "already smaller than that box". One awkward item never aborts the batch.
One neighbouring route deliberately derives nothing. POST /api/admin/media/bulk-mark-optimised (media.upload, body { ids, optimised? }) sets or clears Media.optimised and touches no bytes at all, for images that were compressed before they were ever uploaded - the optimiser reaches the same verdict, but charges a download and a WebP encode per file to get there. Because it is one SELECT and one UPDATE rather than per-image work, it takes the whole selection in a single request instead of being driven an id at a time through runBulkImageJob. The flag is writable in both directions on purpose: it is an admin's assertion, and a wrong one must not be a one-way door.
Media carries no width/height columns; dimensions are read on demand via sharp(...).metadata(). The resize dialog therefore reads naturalWidth/naturalHeight off the loaded preview purely to tell the user what they're getting - the server measures the real bytes itself and is the one that decides.
Every figure the media page shows is derived from Media rows, so the library can only ever agree with itself. Three drifts are therefore invisible to it: an object in storage with no row (each write-new-then-delete-old flow - optimise, relocate, provider migration - has a failure window that leaves one behind), a row whose object has gone, and a row whose sizeBytes disagrees with the object.
GET /api/admin/media/storage-check (config.manage) closes that gap. It returns one slice of the scan at a time: scanMediaStorageChunk(cursor) in lib/media/reconcile.ts answers with a StorageReconcileChunk - a complete report for one key range plus next, an opaque cursor (?cursor=) for the following slice, null on the last. The page walks the slices and adds them up with mergeReconcile (lib/media/reconcile-report.ts, pure and client-safe). A bucket of tens of thousands of objects does not fit in one request's 60 seconds once the usage index is built alongside it, and before slicing the check simply refused anything over 50,000 objects.
Rows are grouped by their own provider - a library that has been through a provider switch holds rows on several, and scanning only the active one would report every other row as missing - and providers are visited in sorted order. S3-compatible providers are sliced by key: listStoredMediaKeysAfter lists from StartAfter until a 15-second budget or 50,000 objects, and the slice is compared against rows whose key falls in (after, lastKey], selected with COLLATE "C" so the database compares in the same byte order the listing comes back in (its own collation folds case and would lose rows between slices). The first slice is open below and the last open above, so a row filed outside the media prefix is still compared, as missing. Vercel Blob and Supabase cannot resume from a key, so they are listed whole in one slice. The comparison itself is a pure function, diffStorageAgainstRows, unit-tested without a bucket or a database - including that slices cut at any boundary sum to exactly the one-pass report: a false orphan here becomes a deleted file downstream, so it is the part worth pinning down.
A provider that cannot be listed returns null, meaning "cannot tell", never an empty list - Cloudinary and ImageKit file under provider-minted ids, so they are reported as unscanned rather than as a library entirely missing from storage. The same applies to an unconfigured provider, a listing that threw, and a single-pass provider over SINGLE_PASS_LIMIT. Any of those sets partial: true with a per-provider reason.
POST repairs, body { action, items: { provider, key }[], force? }, at most 500 items a request (the page batches). None of them re-scans the bucket. Each looks up only the keys it was handed - their rows, and the objects via statStoredMediaObject - and applies the same verdict the scan did (classifyRowlessObject), so the client's list stays a selection, not an authority, without costing a full listing per batch. correct-sizes is non-destructive. delete-orphans additionally requires media.delete and deletes only objects that still read as orphaned. adopt-claimed (media.upload) and purge-missing (media.delete) follow the same rule. statStoredMediaObject throws when it cannot tell, and for S3 providers it is a one-key listing rather than a HEAD: a HEAD against a misnamed bucket is the same bare 404 as a missing object, and "missing" is the answer a purge acts on.
Note that a versioned bucket's own file browser counts superseded versions and delete markers as files, so it will routinely report several times the object count and size that this check does. ListObjectsV2 sees only current versions, which is the set the library should match.
Where the size drift came from. On the direct-upload paths the bytes go from the browser straight to the Worker, so the app never holds them. /record hardens everything else about that request against the body - the key must carry a signature this app issued, the content type is derived from that signed key - but sizeBytes was still the client's own file.size, which describes the file it meant to send rather than the object that landed. confirmedSizeBytes now HEADs the object and records the real length, keeping the claimed figure only when the provider cannot say (the body value still serves the size-cap check, which the Worker enforces independently on the PUT).
Settings > Notifications lists every kind of notification, core's and each module's. A module declares its own in cactus.module.json as notificationKinds: [{ id, label, description?, dedupePrefix, browserDefault? }], where dedupePrefix is the start of the dedupeKey it passes to upsertAlert. Core reads this live (lib/notifications/kinds.ts), so a module needs no core change. A module with more than one kind is shown as an accordion, closed by default. Anything undeclared files under "Other messages" or "Other warnings", so nothing is ever without a switch. Each admin's choices are keyed by kind id ({module}:{id}) in User.notificationPrefs; never rename a kind id.
Env-var changes accumulate into a single deployment notification instead of redeploying on every save. The admin can keep working and click "Redeploy now" when ready, avoiding repeated build cycles. Module install / update / uninstall and core updates do not use this notification: pushing a commit (modules.json for modules, the upstream sync for core) is what ships their code, so they redeploy immediately and surface live deploy status in the admin (see below).
What accumulates into a deployment notification (deferred):
- Saving email or API-key env vars (
POST /api/admin/env) - Applying a Cactus core update only when Vercel creds are missing (fallback path)
The reason label is load-bearing, not decoration. recordDeploymentNeeded dedupes the reasons array by label, replacing the matching entry rather than appending, so two unrelated saves that produce the same string collapse into one line and the second silently overwrites the first. labelForEnvKeys (lib/notifications/deployment.ts) therefore has to be able to tell them apart. It matches four explicit core sets - email, core payment keys, integrations, media storage - and then asks the installed modules which of them declared the key, labelling the save with that module's own display name ("Square Payment For Shop settings updated"), exactly as its card reads on the Modules page. Only a key nothing claims falls through, and it falls through to a neutral "Site settings updated" rather than to a specific claim about what changed.
Until 0.5.1233 there was no media set and no module lookup: media was the fallback, so every module-declared variable in the platform - Square's, GoCardless's, Twilio's, Chatwoot's - was labelled "Media storage settings updated". On a live site that meant saving a Square credential overwrote an existing media entry with identical text: the notification re-surfaced as unread saying precisely what it had said before, and the admin was told to deploy for a reason that had nothing to do with what they had just done. The lookup is deliberately failure-tolerant (a database that will not answer degrades the wording, never the notification), because a deployment nobody is told about is the worse failure by a distance.
What redeploys immediately:
- Installing, updating, or uninstalling a module - commits
modules.json(the git push auto-deploys); live deploy status opens in the admin's notification bell, with a slim status bar pinned to the top of every admin page. - Applying a Cactus core update -
syncCoreFromUpstreampushes a fresh commit tomain(the git push auto-deploys) and the admin watches live deploy status in the notification bell. - Factory reset (
DELETE /api/admin/env) - keeps its immediate redeploy to ensure a clean wipe.
When a push doesn't build. Both commit-driven paths above rely on the git push producing a Vercel build on its own, which is why startDeferredRedeploy polls for that build rather than starting a second one. If no build ever appears - a missed webhook, a GitHub delivery that never lands - it starts one itself instead of giving up. This matters because nothing else in the system retries: the commit would sit in the repo unbuilt, the module rows would still be promoted to versions whose code never shipped, and the site would quietly keep serving the previous build while the admin was told the update had landed. The one clue is a module's stored manifest sitting a version behind its recorded version, since the manifest is only ever rewritten by a build.
Related: a Vercel "redeploy" inherits the source deployment's commit unless told otherwise, so every redeploy Cactus triggers passes withLatestCommit - otherwise the recovery path above would faithfully rebuild the old commit and report success.
Notification table, NotificationType enum: deployment, core_update, module_update, message, alert. The nav bell badge counts any unread notification (getUnreadCount = count({ readAt: null })), so new types light the bell automatically.
| Field | Purpose |
|---|---|
type |
deployment (deferred deploy) or one of the on-demand alert types (core_update, module_update, message). |
title |
Human-readable title shown in the UI. For alerts the title carries the version/count, e.g. Cactus update available - v0.5.110. |
reasons |
JSON array of { label, detail?, at } - each change that contributed to a deployment notification. null for alerts. |
link |
Admin-relative "View X" target for alerts (e.g. /config?tab=general). null for deployment notifications. Rendered as /${adminPath}${link}. |
dedupeKey |
Idempotency handle for on-demand alerts: core-update (one ever), module-update:{moduleId} (one per module), contact-form:messages (one rolling). null for deployment notifications. Indexed. |
readAt |
null = unread (drives the nav bell badge). Set when the admin manually marks read, clicks Redeploy, or clicks a "View โฆ" button. |
deployInitiatedAt |
null = deploy not yet initiated (the "open" deployment notification that new reasons append to). Set when Redeploy is clicked. Always null for alerts. |
"Open" notification - a deployment notification where deployInitiatedAt IS NULL. New reasons always append here. If the admin manually marked it read but hasn't deployed yet, readAt is cleared so the notification re-surfaces when the next change comes in.
Beyond deployment, three alert types are raised on demand by the existing update checks - no cron or new infrastructure, matching the app's on-demand pattern. The bell badge then persists the reminder across the admin until the underlying state clears.
lib/notifications/alerts.ts provides the generic helpers, keyed by dedupeKey:
-
upsertAlert({ type, dedupeKey, title, link })- creates the alert if none exists; if one exists and the title (or link) changed, it re-surfaces it (readAt: null); if the title is unchanged it's a no-op (don't nag a notice the admin already read). Re-surfacing keys off the version-bearing title, so a notice only re-lights the bell when the available version actually changes. -
clearAlert(dedupeKey)-deleteMany({ where: { dedupeKey } }).
Wrappers recordCoreUpdate(latestVersion) and recordModuleUpdate({ moduleId, name, latestVersion }) set the type/dedupeKey/title/link.
Where they're raised and cleared:
| Alert | Raised | Cleared |
|---|---|---|
core_update (core-update) |
GET /api/admin/updates when updateAvailable (the Settings โ General check). Links to /config?tab=general. |
Same GET when no update. The apply (POST /api/admin/updates) deliberately does not clear it: the core version is pkg.version baked into the running build, so the GET re-derives it truthfully - cleared once the new build is live, left lit if the deploy fails. |
module_update (module-update:{id}) |
GET /api/admin/modules/[id] update-found branch (the Modules page check). Links to /modules. |
The GET no-update branch; on a successful update deploy (markModulesDeploySucceeded) or the no-creds optimistic apply; and on uninstall (DELETE). A failed update keeps the alert (the update is still available). |
message (contact-form:messages) |
syncMessagesNotification() (contact-form lib/notify.ts) when unread > 0. One rolling N unread messages notification linking to /m/contact-form/inbox?tab=unread. |
Same helper when the unread count hits zero. |
All triggers are wrapped in try/catch (or fire-and-forget .catch) so a notification failure never breaks the endpoint. The contact-form helper is called after every mutation that changes the unread count: submit, the bulk status/delete PATCH, the single-submission status PATCH / DELETE / open-marks-read, and reply (which marks read).
On the Notifications page, alerts render a leading icon by type (โฌ๏ธ core, ๐ฆ module, โ๏ธ message, ๐ deployment) and a primary "View Update" / "View Messages" button. Clicking it PATCHes the notification read: true then navigates to link - viewing marks it read. Deployment notifications keep their canRedeploy-gated "Redeploy now" action instead.
Per-admin choices (Settings > General > Notifications). User.notificationPrefs (jsonb, null = defaults) holds { [category]: { show, browser } } for the categories in lib/notifications/preferences.ts (message, alert, video, core_update, module_update; a video job is a message row told apart by its video-job: dedupeKey; deployment has no category and is always shown). visibleNotificationsWhere (lib/notifications/visibility.ts) drops switched-off kinds from the bell API, its unread count, the layout badge and the Notifications page. pushBellAlert now receives the alert's type/dedupeKey and sends only to subscriptions whose owner has show && browser for that kind; the open-tab popup (NotificationBell) applies the same rule from the prefs the bell API returns. GET/PUT /api/admin/notifications/preferences read and write the caller's own prefs (config.manage). Core-reconcile 045.
A bell entry worth interrupting somebody for - type message (a new order, a product question, a finished video) or alert (a warning) - is also pushed to every browser an admin has switched alerts on in, so it arrives on a laptop or phone with no admin tab open. Update notices and deployment entries stay in the bell only. The rule lives in lib/push/pushworthy.ts and is shared with the in-tab popups.
When: upsertAlert pushes when it creates a pushworthy alert, and again only when one that had been read comes back unread. An alert still sitting unread has already buzzed once; a progress tick that keeps a read alert read never pushes.
How: lib/push/send.ts (pushBellAlert) sends to every AdminPushSubscription whose user is unsuspended and still has config.manage (the bell's own permission). The payload is sealed per browser (RFC 8291) and signed with the site's VAPID pair (RFC 8292), both on node:crypto in lib/push/web-push.ts - no dependency. The pair is made on first use and stored on SiteConfig (pushVapidPublic, pushVapidPrivateEncrypted, encrypted with ENCRYPTION_KEY); a pair that no longer decrypts after a restore is replaced and every subscription dropped, and browsers re-subscribe on their next admin visit. 5s budget per round, TTL 24h, urgency high; a 404/410 from the push service forgets that browser. Needs an https SITE_URL (the VAPID subject), so localhost sends nothing. Never throws - it runs at the end of payment webhooks.
| Endpoint | Effect |
|---|---|
GET /api/admin/push/worker |
The service worker (lib/push/worker.ts), public, scope /api/admin/push/. Shows each push (tag = notification id, renotify), tells open windows to redraw the bell, and on a click focuses an open admin window and asks it to navigate (or opens one). |
GET /api/admin/push/subscription |
config.manage. The site's public VAPID key (made on first ask). |
POST /api/admin/push/subscription |
config.manage. Keeps PushSubscription.toJSON() against the signed-in user; the endpoint must be Apple, FCM, Mozilla or WNS. |
DELETE /api/admin/push/subscription |
config.manage. Removes this user's row for that endpoint. |
In the browser: the bell button beside Mark all as read / Delete all (components/admin/DesktopAlertsToggle.tsx) subscribes on the press (Safari only asks for permission on the back of one). The bell calls prepareBellPush() once per page load to register the worker, fetch the key and keep the subscription in step with the on/off choice. While the site is pushing to a browser, that browser's in-tab popups stand down so nothing arrives twice; a browser with notifications but no push keeps the older in-tab popups. iPhone and iPad only allow notifications from a Home Screen app: in an ordinary Safari tab the button explains how to add the site to the Home Screen.
The unified-inbox module has its own, separate worker and subscriptions for new-mail nudges; the two do not share anything.
Both places notifications appear - the Notifications page header and the bell dropdown header - offer Mark all as read and Delete all (on both, two icons sharing one capsule, with tooltips, left of the desktop-alerts bell; both ask through the admin's own confirmation dialog, components/admin/useAdminConfirm.tsx, rather than the browser's), backed by two collection-level handlers:
| Endpoint | Effect |
|---|---|
PATCH /api/admin/notifications { read: boolean }
|
updateMany - true stamps readAt on every unread row, false clears readAt on every read one. Returns { ok, updated }. |
DELETE /api/admin/notifications |
deleteMany over everything except open deployment notifications (see below). Returns { ok, deleted, kept }. |
Both require config.manage, like the rest of the notification routes.
They act on the whole table, not on the rows the caller happens to be holding: the page renders the 100 most recent and the bell 20, so a "mark all as read" scoped to what was on screen would leave the badge stubbornly non-zero. Mark-all is disabled at zero unread; both are hidden when there is nothing to act on.
Delete all spares the open deployment notification. Everything else on the list is history, or re-derivable state that writes itself back: a video-optimise job mid-encode re-creates its notification on the next progress tick, and the core/module update alerts are re-raised by the next update check (all of them upsert by dedupeKey). An open deployment notification is neither. It is the only record of changes that are saved but not yet live, the only route to the "Redeploy now" button, and nothing re-creates it - recordDeploymentNeeded writes only when the next change comes in. Deleting it would leave the site quietly serving stale config with no reminder that anything was pending, so the endpoint excludes it and reports it back as kept.
Both UIs say so before they act, and disable Delete all when the spared notification is all that is left. The page's confirmation names the number that will really go (total minus pending), which can still be larger than the hundred rows on screen. To clear a pending deployment notification, deploy - or delete that one on its own, which is still allowed.
- Admin saves an env var (or applies a core update on an install with no Vercel creds).
- The change handler calls
recordDeploymentNeeded({ label })(lib/notifications/deployment.ts). - The helper upserts the open deployment notification, appending the reason (deduped by label).
- The nav bell badge shows the unread count. A dismissible banner appears across admin pages.
- When the admin is ready, they visit Notifications and click "Redeploy now".
-
POST /api/admin/notifications/[id]/redeploy:- Marks the notification
readAt = now, deployInitiatedAt = now. - Flips any
pending_deploymodules todeploying. - Calls
startDeferredRedeploy()(lib/deploy/redeploy.ts) - see "Shared redeploy helper" below.
- Marks the notification
Module install / update / uninstall and core updates ship their code by pushing a commit, so there is nothing to defer. After the DB work:
- The module row is set to
deploying(install/update) or deleted (uninstall). For a core update there is no row -syncCoreFromUpstream(lib/updates/core.ts) has already committed and pushed the new core files tomain, which auto-deploys. - The handler calls
startDeferredRedeploy()and returns{ redeployTriggered: true }. The core-update route (POST /api/admin/updates) passesstartDeferredRedeploy({ committedSince })- the timestamp captured just before the sync push - so the helper captures that build instead of triggering a second one. - The client calls
announceRedeployStarted()(lib/deploy-status-client.ts), which opens the notification bell's live deploy status section; the top-of-shellDeployStatusBarshows the same headline. The admin keeps working while the build runs. - If Vercel creds are missing,
startDeferredRedeploy()returns{ triggered: false }and the handler falls back to the deferred-notification flow (pending_deploy+recordDeploymentNeededfor modules,recordDeploymentNeededfor core updates) so non-Vercel installs still work.
scripts/checkout-modules.mjs clones each module at the tag recorded in its modules.json entry (git clone --depth=1 --branch <version>). An entry with no version at all - a hand-edited registry - is cloned at the repo's default-branch HEAD. This applies on every build, Vercel or local. What happens when a pinned clone fails now depends on where the build is running: on Vercel it retries once and then fails the build outright, while locally it warns and falls back to HEAD. See A pinned module must ship its pin below.
This matters because modules.json is committed independently of any specific admin action - a core update, a different module's install/update/uninstall, or a plain "Redeploy now" can all trigger a build. Before the pin was enforced, the clone ignored version entirely and always fetched upstream HEAD, so any deploy silently advanced every installed module to whatever was on its default branch, regardless of what Module.version (and the Modules page) claimed was installed. Now the registry's version field is authoritative: a module's code only changes when something explicitly updates its modules.json entry.
The core-update confirm dialog (app/cactus-admin/config/page.tsx) offers a checkbox, ticked by default when any installed module has an update, to bundle those module updates into the same deploy:
-
GET /api/admin/updatescallsfindModuleUpdates()(lib/modules/updates.ts) - a pure, non-persisting live check against each installed module's latest release - only when a core update is available, and returns the list asmodulesWithUpdatesfor the dialog to display and default-check. -
POST /api/admin/updatesaccepts{ updateModules: boolean }. When true, it re-runsfindModuleUpdates()and, under the same deploy lock as the core sync, sets each stale module tostatus: 'deploying', pendingVersion: <latest tag>(clearingupdateAvailable/updateNotes) before callingsyncCoreFromUpstream. - Regardless of
updateModules, the route always passes the full module list tosyncCoreFromUpstream(fromVersion, toVersion, modulesJson), where each entry's version ispendingVersion ?? version.syncCoreFromUpstream(lib/updates/core.ts) now accepts this optionalmodulesJsonparam and, when given, adds amodules.jsonblob to the same tree/commit as the diffed core files (the path is excluded from the upstream diff itself, alongsidemodules/and.gitmodules). This is what makes the checkbox actually control module code: modules left off the queue keep their current confirmed version pinned (so checkout-modules.mjs re-clones the same code, not upstream HEAD), while queued modules get their target tag pinned - all in one push, so there is still only one build. - If the push fails, queued modules are rolled back to
update_availablein thecatchblock. If Vercel creds are missing (triggered: false), queued modules are promoted optimistically (pending_deploy+versionset) the same way the no-Vercel-creds module-update fallback does, since there's no deploy left to reconcile them.
The install dialog (app/cactus-admin/modules/page.tsx) is the mirror image of the core-update one. Clicking Install only opens a dialog when there is something to bundle - a pending core update, or other modules with updates waiting - and otherwise installs straight away as it always did.
-
The decision is made on a fresh check, taken when Install is clicked.
requestInstall()is async: it awaitsloadCoreUpdate(true)(aGET /api/admin/updatesthat bypasses thecactus-core-update-checksessionStorage cache and rewrites it) and a forcedcheckModuleUpdate(id, true)for every installed module, with the button showing Checkingโฆ meanwhile, and only then decides whether to open the dialog. The mount-time reads are still there for the page's own badges, but they are throttled ten minutes each and fired without awaiting, so deciding off them meant a page opened just before a release went out - or clicked before its own checks landed - installed silently with nothing offered. One round trip per Install click is nothing beside the build it starts. - A
403on/api/admin/updates(someone withmodules.managebut notconfig.manage) is a normal answer: the core checkbox simply isn't offered. A module counts as waiting if the fresh check says so or its row already readsstatus: 'update_available'- the check only ever promotes that status, never clears it, andresolveUpdateBatch()drops any no-ops server-side anyway. -
POST /api/admin/modulesaccepts{ updateCore?: boolean, updateModules?: boolean }alongsiderepoUrl/channel.updateCorere-checkshasPermission(user, 'config.manage')server-side, sincemodules.managealone must not be enough to push a core update. - With
updateCore,getCoreUpdateStatus()resolves the target version and every compatibility check in the route is then judged against that incoming core, not the running one (effectiveCoreVersion) - core files andmodules.jsonland in one commit, so a module that needs the incoming core is satisfied by the build carrying it. A module refused withcore_version_requiredtherefore becomes installable in a single deploy, and the prerequisite dialog offers Update Cactus and install when the waiting version is new enough. - With
updateModules, the pending updates are resolved before the new module's ownrequiresModulesgate, through the sameresolveUpdateBatch()compat gate as "Update all", and the new module is then judged against the versions this build will actually carry. So installing a module that needs a newer sibling works in one go instead of being refused with "update it first". Anything the gate blocks comes back inmoduleUpdatesSkippedand is reported to the owner verbatim. - When a core update is bundled,
syncCoreFromUpstream(...)does the committing (carrying the fullmodules.json, new module and queued updates included) andstartDeferredRedeploy({ committedSince })adopts that push's build. Without one, the plainstartDeferredRedeploy()module-registry sync commits as before. - Failure handling matches the core-update route: queued rows roll back to
update_available, the new module goesfailed, the deploy lock is released, and a GitHub outage or a blown deadline answers503with an explanation rather than a hard kill. Work before anything is written is bounded bydeadlineFromNow(ROUTE_WORK_BUDGET_MS - elapsed).
startDeferredRedeploy(opts?) (lib/deploy/redeploy.ts) is used by the notification redeploy route, the module routes, and the core-update route:
- Sets
SiteConfig.pendingRedeployId = 'pending'andpendingRedeployAtsynchronously so the admin's deploy status surfaces show up immediately. -
committedSincemode - when the caller has already pushed a commit that triggered a build (e.g. a core update viasyncCoreFromUpstream), it passes{ committedSince }(the timestamp captured before that push). Theafter()callback then skips the module sync andtriggerVercelRedeploy()fallback entirely and just polls Vercel for the build the existing push created, storing its UID. This avoids a double-deploy. The deploy lock guarantees no other deployment exists in that window, so thecreated > committedSincefilter reliably catches the right build. - Otherwise (no
committedSince), theafter()callback callssyncModulesJson(desired)(lib/modules/github.ts), wheredesiredis derived from everyModulerow (name,repoUrl, andpendingVersion ?? version- so an in-flight update ships its target tag, see "Confirmed vs in-flight version" below):- If the registry differs from git, it commits
modules.jsononce. That push is what triggers the Vercel build, sotriggerVercelRedeploy()is not called (calling it would double-deploy); the helper polls Vercel for the build the push created and stores its UID. - If the registry already matches git (e.g. an env-var-only redeploy), no commit is made and
triggerVercelRedeploy()rebuilds the current HEAD to pick up env-var changes.
- If the registry differs from git, it commits
- If the deployment-id poll comes up empty, the
'pending'sentinel is left in place rather than nulled (nulling would hide the deploy status from the admin mid-build). The server-side 2-minute auto-release inresolvePendingRedeploy()(lib/config/site.ts) is the backstop. - From here the client deploy-status poller (
lib/deploy-status-client.ts, feedingDeployStatusBarand the notification bell) and Vercel webhook take over. The webhook and the other "deploy finished" paths reconcile the module rows (see "Confirmed vs in-flight version" below), anddeployment.succeededclearspendingRedeployId.
A module update must not appear to have landed if the build that ships it fails. Two fields separate intent from reality:
-
version- the confirmed installed version: the tag of the build that is actually live. This is what the Modules page shows as the installed version (installedVersion). -
pendingVersion- the in-flight target while a deploy is running;nullotherwise.
Clicking Update writes pendingVersion = release.tag and status = 'deploying' but leaves version, updateAvailable, and updateNotes untouched. The deferred sync ships pendingVersion ?? version so the build clones the new code. The confirmed version only moves when the deploy is confirmed successful.
All three "deploy finished" paths funnel through lib/deploy/reconcile.ts so they can't drift:
-
markModulesDeploySucceeded()- promotesversion = pendingVersion ?? version, clearspendingVersion/updateAvailable/updateNotes/lastError, setsactive, and clears the per-module update alert. -
markModulesDeployFailed(reason)- dropspendingVersion, keeps the liveversion, and reverts a mid-update module toupdate_available(clean retry; the failure was already shown in the admin's deploy status surfaces) or marks a failed installfailedwith the reason.
The callers: the Vercel webhook (Pro), the Modules-page check-status poll (Hobby), and the deploy-status dismiss (DELETE /api/admin/redeploy-status, which now checks getLatestDeploymentStatus() instead of blindly activating). pendingVersion ?? version leaves install flows (no pendingVersion) untouched.
The no-Vercel-creds fallback (pending_deploy) has no deploy to track, so it stays optimistic: it promotes version immediately and clears the alert.
| Status | Meaning |
|---|---|
pending_install |
Transient - row created during install, before the DB row is finalised. |
pending_deploy |
Fallback only (no Vercel creds): change recorded, awaiting admin Redeploy. The normal module path goes straight to deploying. |
deploying |
Redeploy initiated; Vercel build in progress. Module install / update / uninstall set this immediately. While here, pendingVersion holds the target tag; version is still the live one. |
active |
Deployed and live. version reflects the build that shipped. |
inactive |
Manually disabled by admin. |
update_available |
Newer release exists and hasn't been applied - or a previous update deploy failed and the row reverted here for a clean retry. |
failed |
A previous install deploy failed (a failed update reverts to update_available instead). |
Modules are git submodules living under modules/<name>/. Installing one:
-
POST /api/admin/modulesfetchescactus.module.json, validates the manifest, acquires the deploy lock, and creates theModulerow. GitHub credentials (resolved bylib/github/client.ts: prefers a connected GitHub App installation token, falls back toGITHUB_API_TOKEN) are needed to commitmodules.jsonduring the redeploy. The App manifest defines no webhook, so GitHub returnswebhook_secret: null; Cactus stores it as NULL and never reads it - only the private key (pem) is used for API authentication. - The module status is set to
deploying, the deploy lock is released, andstartDeferredRedeploy()commitsmodules.jsonand captures the build (see Immediate deploy flow above). The handler returns{ redeployTriggered: true }and the client opens the notification bell's live deploy status. Update and uninstall work the same way: they mutate the database (update the row, or delete it) then redeploy immediately. The desiredmodules.jsonis fully derivable from theModulerows. - During Vercel's build step (after the redeploy is triggered),
scripts/run-module-migrations.mjsruns afterprisma migrate deploy. It finds all active modules' SQL migration files, checks theModuleMigrationtable for already-applied ones, and executes the rest in lexicographic order. - Immediately after migrations,
scripts/sync-module-manifests.mjsrewrites every installed module'sModule.manifestcolumn from its deployedcactus.module.json. The manifest is therefore no longer install-time only - it tracks the deployed module code on every build. This self-heals stale data: removed nav entries disappear from the admin sidebar, and changedteardown/cookieCategorieslists stay in sync without any GitHub fetch or runtime cost. It never inserts new rows; a missing or unparseable file is logged and skipped. - The Vercel webhook (
deployment.succeeded) reconciles the module rows viamarkModulesDeploySucceeded()(promotingpendingVersion โ version) and releases the deploy lock;deployment.error/canceledcallsmarkModulesDeployFailed(). On Hobby plans without webhooks, the Modules page pollscheck-statusinstead, and dismissing the deploy status reconciles against the real deployment state. See "Confirmed vs in-flight version" above.
Module database tables are prefixed (tablePrefix field, e.g. forum_). They never touch Prisma's migration history. The core Prisma client knows nothing about module tables - modules query their own tables directly.
DeployLock is a single row (id = "singleton") that makes install, update, core update and uninstall mutually exclusive; a second attempt while it is held gets a 409. It is released by the handler that took it, by the Vercel deploy webhook, or by the redeploy-status handler.
None of those runs if the function holding it is hard-killed - a Vercel function timeout or an OOM - and the row then sat there forever, turning every subsequent install or update into a permanent 409 that the admin UI offers no way out of. Every acquire and check site goes through lib/deploy/lock.ts, never a bare findUnique/create:
-
acquireDeployLock(lockedBy, holdMs?)takes the lock and stampsDeployLock.expiresAtwith how long this holder's work can credibly run. The default (DEFAULT_LOCK_HOLD_MS, 90 seconds) suits every current caller: each one releases the lock before the build it triggers starts, and each route caps atmaxDuration = 60, so a hold cannot legitimately outlive its function. A caller that holds it across something longer passes its ownholdMs. -
getActiveDeployLock()deletes and reports "no lock" for a row past itsexpiresAt. A row with no expiry - written by an older build, or by a caller that did not stamp one - falls back toSTALE_LOCK_MS(15 minutes). The delete is scoped to the exactid+lockedAtpair, so a fresh lock taken by another request between the read and the delete is never removed by mistake. -
lockBusyMessage(lock)is the 409 body: it names how long the wait is (secondsUntilLockClears) rather than saying "please wait", because the usual reason for seeing it twice is a previous attempt that died, not one still running.
The expiry is what makes a failed update survivable. Before it, one hard kill locked the owner out for a quarter of an hour - which, on a day when GitHub is degraded and every attempt dies at 60 seconds, is a fifteen-minute wait per attempt.
Being killed at the function ceiling is the worst outcome available: no catch block runs (lock stranded), and the browser gets the platform's HTML timeout page instead of JSON - which res.json() then chokes on, showing the owner a browser parser message ("The string did not match the expected pattern." in Safari) as the entire explanation for a failed update.
So the work is given a budget that ends before the ceiling (lib/updates/deadline.ts: ROUTE_WORK_BUDGET_MS = 60s minus 10s headroom). syncCoreFromUpstream(..., { deadlineAt }) checks it where abandoning the run is still free - after reading the release trees, per file while downloading blobs, and before opening the push transaction (which needs PUSH_MIN_BUDGET_MS, 15s, to be worth starting). It is deliberately not checked inside the commit transaction: once the push has started, finishing it is what keeps the repo consistent. retryTransient also takes deadlineAt and refuses to sleep past it - its default budget is minutes, which inside a 60-second route guarantees the kill it was meant to survive.
An abort throws DeadlineExceededError ("Nothing was changed - this usually means GitHub is responding slowly"), so the handler rolls back its queued module rows, releases the lock, and answers 503 with a real sentence.
lib/github/health.ts reads GitHub's own status page (githubstatus.com/api/v2/summary.json) and reports whether API Requests, Git Operations or Webhooks are non-operational. It is cached for a minute, times out after 2.5 seconds, and treats every failure as "no known problem" - a status page that will not answer must never block an update.
The core-update handler calls it only on failure, appending e.g. "GitHub is currently reporting problems with Git Operations (major outage)." to the error, and downgrades the response to 503. Client-side, looksLikeGitHubProblem() (lib/updates/github-outage.ts) matches that wording plus deadline aborts, and the admin adds a link to GitHub's status page underneath.
Both admin surfaces read update responses through readJsonResponse() (lib/updates/read-json-response.ts) rather than res.json(): a gateway status or an HTML body becomes "The update ran out of time before your site heard back. Nothing was changed..." instead of a parser error.
checkModuleUpdateCompat() (lib/modules/compat.ts) is the shared gate: it reads the module's manifest at the tag about to be installed and returns a short human-readable reason when the running core or the installed module set fails to satisfy requiresCoreVersion / requiresModules, or null when it is fine. A manifest that cannot be fetched counts as compatible, deliberately, so a transient GitHub failure never blocks an otherwise-fine update.
The single-module install and update paths have always run this check. The bulk "Update all" path did not: it pinned every module's latest tag into modules.json and let the build find out. One module wanting a newer core than the site is running then broke every subsequent build on a missing import, with no obvious culprit. Bulk update now runs the same gate per module and drops incompatible ones into its failed[] result instead of pinning them.
fetchManifestFromRepo(repoUrl, filename, ref) takes the git ref to read at. Install and update pass the release.tag they are shipping, so requirements are validated against the version actually being installed. It defaults to HEAD only for callers that genuinely want the current default branch, and reading a manifest at HEAD while installing a tag is a bug, not a shortcut.
scripts/checkout-modules.mjs clones each modules.json entry at its recorded version tag. On Vercel it no longer falls back to an unpinned HEAD clone when that fails: it retries the pinned clone once, to absorb a transient network blip, and then exits non-zero and fails the build. Silently shipping HEAD would put unvetted module code - possibly needing a newer core - into production while the database and modules.json both still claim the pinned version, which is a lie the rest of the system has no way to detect. Locally nothing is fatal: a deleted tag or a network blip still warns and falls back, so local work carries on. scripts/prebuild.mjs likewise treats a child killed by a signal as a failure; a signalled child reports a null exit code, which previously read as success.
The four manifest-driven generators (generate-module-puck.mjs, generate-module-settings-tabs.mjs, generate-module-sms-providers.mjs, generate-module-extension-points.mjs) dedupe the import lines they emit. A module can legitimately contribute the same component to two extension points, two settings tabs or two block types, and the generated identifier is derived from module name plus component name, so the same import was written twice and the generated file did not compile. A repeat is now collapsed; the same identifier mapping to two different paths is a genuine name clash and throws, failing the build rather than emitting a duplicate declaration.
npm run build is node scripts/prebuild.mjs && node scripts/next-build.mjs. The prebuild is a fan-out, not a queue: scripts/checkout-modules.mjs runs first as a barrier, because everything after it reads the module code it clones into /modules. Once that is done, three branches run concurrently, since none of them reads another's inputs or writes another's outputs:
-
prisma generate(writes the Prisma client intonode_modules) - the database chain:
build-migrate.mjs(prisma migrate deployโreconcile-core-schema.mjsโrun-module-migrations.mjsโ conditional query-plan cleanup) followed bysync-module-manifests.mjs -
generate-all.mjs(the code generators that write the gitignored files inlib/)
Each branch's log output is buffered and printed as one labelled block when it finishes, so concurrent output cannot interleave, and any branch exiting non-zero fails the whole build. Each block header carries that step's duration and the prebuild prints its total, because buffering means every line in a block shares one timestamp in the deployment log and could otherwise say nothing about which branch was the long pole.
Three import-graph checks run after the fan-out, because they need both the module code and the generated wiring on disk, and so see this install's own module set rather than the one on a developer's machine. Each is also a vitest case in lib/modules, so a leak introduced in core shows up in npm test before a deploy does.
-
check-client-graph.mjsfails the build if a'use client'file can reach server-only code (the database client,sharp,nodemailer,next/headersand the like) through any import edge, dynamicimport()included. -
check-server-client-imports.mjsis the same boundary from the other side. In the server layer a'use client'module is a proxy: it can be rendered, or handed to a client component, but reading anything else from it throws, soticked[SOME_CONSTANT]on the server returns a 500 for every request whiletsc,eslintand the unit tests stay green. It walks from the real server entries (route handlers, pages, layouts, the rootproxy.ts) without stepping through a'use client'file, and looks at how each imported name is used: rendering it, passing it to a JSX attribute andimport typeare fine, while calling it, reading a property or element from it, using it as a key or in a template string are not. Only a throwing use in a file that no client component can also reach fails the build. A comparison against client data (silently always false), a value handed to a function, or anything in a file shared with the client is printed as a warning, because that code may only ever run in the browser. The fix is always the same: put the value in a plain file with no'use client'and import it from there in both places. -
check-import-cycles.mjsfails the build if a generated registry sits in an import cycle with the module code it imports.
scripts/next-build.mjs is the second half: next build under a watchdog. A build that has printed nothing for ten minutes, or has been running for twenty-five, is stuck rather than slow, so the wrapper kills its whole process group, deletes .next/cache/turbopack and runs it once more with the Turbopack build cache off (CACTUS_TURBOPACK_BUILD_CACHE=0). A build killed by a signal nobody sent - the kernel reclaiming memory - gets the same cold retry; a build that fails on its own merits does not, since repeating it only buys the same error twice. The watchdog is armed on Vercel and in CI only, and both limits sit inside Vercel's 45-minute deployment budget so the retry has room to finish. This exists because a build machine downsized to 4 cores / 8 GB once made Next 16.2's Turbopack thrash for the full 45 minutes and print nothing at all; Next.js is now pinned at ^16.3.4, where the Turbopack memory work landed, and that floor should not be lowered.
Turbopack's build filesystem cache is enabled (experimental.turbopackFileSystemCacheForBuild in next.config.ts). It persists compilation work under .next/cache, which Vercel restores from the previous deployment. Reuse depends on retaining that directory; a successful cache restore followed by a cold compile can mean the previous build's cleanup deleted the compiler's part.
scripts/prune-build-cache.mjs runs only after a successful build. If dependencies and compilation cache fit its 1,400 MB on-disk safety budget, it leaves them alone. If not, scripts/lib/build-cache.mjs packs the complete compiler database with Brotli quality 4 before sacrificing dependencies. A bounded stream hashes each file and publishes a manifest last. The next scripts/next-build.mjs restores into a staging directory, checks paths, sizes, hashes and compiler/machine identity, and only then replaces the raw cache. Corrupt or incompatible snapshots are discarded and Next.js starts cold. CACTUS_TURBOPACK_BUILD_CACHE=0 discards the packed snapshot without restoring it; a watchdog retry clears both forms.
If packing alone does not fit the budget, pruning removes only enough untraced packages, tooling first. Traced packages, their top-level parents when a nested dependency is traced, and next/@next/* always survive because Vercel still needs them to assemble functions. Invalid or unreadable traces prevent dependency deletion. If the compiler cannot fit even after all eligible packages were removed, it is discarded before sacrificing the dependencies too. This replaces the old routine of deleting both caches and then rebuilding both. No individual Turbopack database file is ever trimmed. CACTUS_PRUNE_BUILD_CACHE=0 disables cleanup, =1 explicitly arms it locally, and CACTUS_BUILD_CACHE_BUDGET_MB changes the safety budget. Earlier packed snapshots still restore when cleanup is disabled.
lib/documents/chromium.ts marks its local existsSync executable probes with turbopackIgnore. These paths belong to an externally installed browser, not project assets; leaving the variable unmarked made Turbopack trace the whole project into server output. Local browser discovery and deployed Chromium selection are unchanged. This change requires examining a subsequent build's traces to verify the reduction; source tests alone do not measure deployment size.
Core ships a single Prisma migration that is edited in place for schema changes rather than adding new migration files. That keeps fresh installs simple, but it has a sharp edge: prisma migrate deploy records a migration as applied by name and never re-runs it, so any additive change made to the core migration after a site was first provisioned never reaches that site's database. The drift stays invisible until a page queries a table or column the frozen database doesn't have.
scripts/reconcile-core-schema.mjs closes that gap. It runs on every deploy, immediately after prisma migrate deploy, and applies each not-yet-applied .sql file in prisma/core-reconcile/ (in lexicographic order). Applied files are recorded, with a checksum, in the existing ModuleMigration table under the reserved module name __core__, so a file that has already run on this install is skipped on later deploys rather than re-executed as a no-op; a file whose contents change is re-applied. Every statement in those files is additive and idempotent - CREATE TABLE IF NOT EXISTS, ADD COLUMN IF NOT EXISTS, CREATE INDEX IF NOT EXISTS, and foreign keys guarded by a pg_constraint name check. On a fresh or up-to-date install every statement is a no-op; on a drifted install it adds exactly the missing objects and nothing else. The files are never used to drop, rename, or retype anything - reconcile only ever moves an old database forward to the additive parts of the current core schema.
The reconcile files heal installs provisioned before recent core features landed: the media library's Folder, Tag, and MediaTag tables plus the Media.originalName, Media.folderId, and Media.optimised columns (what a site set up on an earlier release needs before the admin media page can load), and SMS-based member two-factor (MemberTwoFactor.phoneEncrypted and the SMS value on the two-factor method enum). The enum change lives in its own file as the only statement, because ALTER TYPE ... ADD VALUE cannot be batched with other statements in one transaction on older PostgreSQL.
Postgres plans a prepared statement against the table shape it saw when the statement was prepared. For a SELECT * that shape is every column of the table, so changing the table invalidates the plan: the next execution on a connection still holding it fails with cached plan must not change result type (SQLSTATE 0A000).
A deploy normally makes this a non-issue, because it replaces the application and its database connections together. It is not a non-issue through a pooler. Neon's pooled endpoint is pgBouncer, and pgBouncer keeps its own server connections open across deploys, while the migration chain above deliberately runs its DDL on the direct endpoint and so never touches them. A schema change therefore strands stale plans on the pool for as long as pgBouncer keeps those connections alive.
The symptom is a site that only half works. A request routed onto a fresh pooled connection succeeds; one routed onto an older connection returns a 500; which happens varies from request to request, and it clears up on its own once the connections are recycled - potentially an hour or more later.
scripts/lib/plan-flush.mjs supplies the cleanup used by build-migrate.mjs and the standalone scripts/flush-pooled-plans.mjs. It ends idle backends only, excludes its own connection, and sweeps twice, three seconds apart. The tracking connection uses the direct endpoint and holds a database advisory lock across the migration chain, so cooperating deployments cannot overlap.
Before any migration, the runner fingerprints the Prisma and module migration ledgers and writes a durable pending marker to the existing ModuleMigration table (__core__ / __query_plan_flush_v1__). After the runners finish, cleanup is skipped only if the before/after fingerprints both match the last completed sweep. First adoption, a changed migration, an interrupted attempt, or a failed sweep therefore still causes both passes. The completed marker is written only after success. No new table, column or migration is required. On a fresh database without ledgers the first sweep is unconditional. If the post-migration bookkeeping fails, the runner attempts the unconditional sweep and leaves recovery for the next deployment. Failure to establish tracking before migrations aborts before DDL instead of guessing that cleanup is unnecessary. Closing the connection releases the lock.
The standalone cleanup command remains unconditional and best-effort. The real SQL and connection behaviour are exercised by RUN_PLAN_FLUSH=1 npx vitest run scripts/lib/plan-flush.live.test.ts --no-file-parallelism, which creates and drops its own throwaway database using the existing OVH test credentials. OVH_DB_HOST may supply the certificate hostname; the application's DATABASE_URL is never used by this suite.
Two changes on 2026-07-19, after shop's 006_supplier migration took every product page, the product editor and the CSV export on a live site down with this error:
-
The flusher was being handed the wrong URL.
build-migrate.mjsreplacesDATABASE_URLwith the direct endpoint for its children, so the flusher's "is this pooled?" test was reading a hostname from which-poolerhad already been stripped. It concluded there was no pooler and exited without clearing anything - on precisely the installs that need it. The flusher now receives the site's realDATABASE_URL, and the pooled/direct test no longer decides whether to sweep, only which line to log. A silent wrong answer there costs a broken storefront one migration later, which is not a trade worth making to skip one query. -
A runtime retry, which is the actual guarantee. A build-time sweep cannot help a connection opened after the build, so
lib/db/stale-plan.tsreplays any query that fails with this error exactly once. The first failure is what discards the stale plan, so the replay re-plans against the real schema and succeeds. It is wired into the shared Prisma client as a$allOperationsextension, which means it covers raw queries as well as model calls, and covers every module, since they all import that one client. It matches on the error message rather than SQLSTATE0A000, which is Postgres's generic "feature not supported" and covers plenty of errors that must never be retried.
The sweep is now the thing that keeps the retry rare, rather than the thing standing between a migration and a broken site.
Modules can register Puck blocks that appear in both the page builder and the layout builder. Declare them in cactus.module.json under puckBlocks:
"puckBlocks": [
{
"type": "ContactForm",
"import": "./components/puck/ContactFormBlock",
"component": "contactFormPuckComponent",
"rscComponent": "contactFormPuckRscComponent"
}
]During each build and dev start, scripts/generate-module-puck.mjs scans all installed modules' manifests and rewrites lib/puck/module-components.ts with the correct import statements. The generated moduleComponents and moduleRscComponents records are then spread into puckConfig.components, layoutPuckConfig.components, and their RSC variants so the blocks appear under a Modules category in the block picker. lib/puck/module-components.ts is gitignored and never committed - it mirrors lib/modules/router.ts in this respect.
A module block can also opt into the header chrome instead of (or as well as) a content layout: tag it with layoutTypes: ["header"] in the manifest and the generator buckets it into moduleComponentsByLayoutType['header'] / moduleRscComponentsByLayoutType['header'], which headerPuckConfig and headerPuckRscConfig merge into the header's block picker under a Blocks category. This is how the shop module's Shop: Cart Summary widget reaches Appearance โ Header. The mechanism is generic - no module-specific code lives in the core header config - and the reserved "header" key lines up with the getConfig switch, which already treats header (and footer) as layout types.
Block settings should live entirely in the block's Puck field definitions - not in a separate settings page. This gives each instance of the block its own independent configuration. Abuse-sensitive settings (API keys, rate limits, notification emails) must be kept server-authoritative: the submit handler should re-derive the block's config from the page or layout's saved builderData using the block's id, never trusting values sent by the browser.
A module can optionally own a top-level public URL segment by declaring publicBasePath in its manifest (e.g. "gazette"). This is generic, reusable infrastructure - core has no knowledge of any specific module's public routes, only the mechanism.
scripts/generate-module-router.mjs scans every installed module's app/public/<base>/ directory (same manifest-driven pattern as the admin/API scan) and emits these exports into the gitignored lib/modules/router.public.ts (see Why the router is two files below):
-
resolveModulePublicPage(base, path)- resolves a module'spage.tsxfor a given base + path, matching literal segments before dynamic[param]segments so resolution is deterministic. -
dispatchModulePublicRoute(base, path, method, req)- resolves and invokes a module'sroute.tshandler. -
getModulePublicBases()- the list of all installed bases. -
resolveModuleRootSlugPage(slug)(generated intolib/modules/router.public-slug.ts, re-exported here) - asks each module that declaredpublicRootSlugwhether it owns a bare top-level slug (see below). -
collectModuleSitemapEntries(siteUrl)- callsgetPublicSitemapEntries(siteUrl)from each module'slib/sitemap.ts(if present), swallowing per-module errors. -
collectModuleRobotsDisallow()- callsgetPublicRobotsDisallow()from each module'slib/robots.ts(if present), swallowing per-module errors.app/robots.txt/route.tsmerges the returned paths into itsdisallowlist. Shop uses this to pull every shop URL out of bothsitemap.xmlandrobots.txtwhileshopStatusis set to Closed in Settings - no redeploy needed, both routes areforce-dynamic. -
collectModulePublicHead(siteUrl)(generated intolib/modules/public-head.ts, re-exported here) - callsgetPublicHead(siteUrl)from each module'slib/head.ts(if present), swallowing per-module errors, and returns{ jsonLd, meta, links, scripts }.app/(public)/layout.tsxrenders eachscriptsentry as a plain inline<script id>first in the body (before the header, so it runs before anything paints - shop's with/without VAT switch writes the shopper's saved side into a<style>in<head>this way - never onto<html>, whose attributes React strips when it takes the element over), each JSON-LD object as a<script type="application/ld+json">and each meta entry as a<meta>, so a module can contribute to every public page - core pages and other modules' pages alike - rather than only to pages it serves itself (core 0.5.1418). Its live consumer isultimate-seo, which emits the site-wide Organization/WebSite structured data and thetwitter:sitetag from its saved profile. JSON-LD is deliberately rendered in the body rather than the head: search engines read it from either, and a layout has no way to contribute to<head>per request. -
collectModuleRobotsGroups()andcollectModuleRobotsExtraLines()(core 0.5.1565) - the AI half of the same contract, and both optional. A module may also exportgetPublicRobotsGroups(), returning rule groups aimed at named crawlers, andgetPublicRobotsExtraLines(), returning lines that go inside the catch-all group and have no directive of their own (aContent-Signal:line, a comment). The generator emits guarded calls read throughRecord<string, unknown>, so a module written before either existed - which is all of them - is unaffected. A named group inherits core's base disallow list: a crawler obeys only the most specific group that names it and ignores every other group in the file, so a group saying "yes, GPTBot may read us" would otherwise be handing that one crawler/cactus-admin/,/setup/and/api/as well. A group that already saysDisallow: /is left alone.
/robots.txt is a route handler, not a metadata route (core 0.5.1565). It used to be app/robots.ts returning Next's MetadataRoute.Robots; it is now app/robots.txt/route.ts serialising through lib/seo/robots-txt.ts. The reason is that Next's type has a field for a user agent, an allow, a disallow, a crawl delay and a sitemap, and nothing else - and two of the things a site now says to AI crawlers are neither. A Content-Signal: line (what a crawler that has fetched the page may then do with it) and a plain comment pointing at /llms.txt have no field to live in. Output for a site with no AI rules set is what the metadata route produced before, byte for byte, and the file stays force-dynamic for the same reason it always was: it reads the live SiteConfig, so a static copy leaves a fresh install blocked from crawlers no matter what the owner unticks.
Three addresses for language models (core 0.5.1565), owned by core and filled in by whichever module registers on core.agent-content:
| Address | Route | What it is |
|---|---|---|
/llms.txt |
app/llms.txt/route.ts |
A Markdown index of the site, for a reader choosing what to fetch |
/llms-full.txt |
app/llms-full.txt/route.ts |
The same index with the shorter documents inlined |
/<any-page>.md |
app/api/agent-content/markdown/[...path]/route.ts |
The Markdown twin of that page |
The .md address is reached by an afterFiles rewrite in next.config.ts, '/:mdPath(.*\\.md)', which captures the path with its suffix intact so the route can tell /guide.md (a twin) from a page whose slug is genuinely guide.md. All three apply the same gate /robots.txt does - a site hidden from crawlers, or not yet live, 404s them (lib/agent-content/visibility.ts) - and all three read the full extension-point map rather than the public one, because their providers query the whole catalogue and are registered serverOnly. That is safe, and deliberate: a route handler has its own function bundle and renders no React, so nothing it imports can reach a page. router-split.test.ts lists app/robots.txt/route.ts among the public render paths and pointedly does not list these.
x-cactus-path (core 0.5.1565). proxy.ts forwards the address the visitor asked for as a request header, via NextResponse.next({ request: { headers } }). A server component cannot ask what path it is on - there is no API for it, and the client component that could would mean shipping JavaScript to every page to answer a question the proxy already knows. Read by ultimate-seo to record which page an AI crawler fetched, and to look up the page's own structured data and the address of its Markdown twin.
The assigned homepage has exactly one address (core 0.5.1577). The page named in SiteConfig.homepageId is rendered by app/(public)/page.tsx at /, and the same row was simultaneously served by app/(public)/[slug]/page.tsx at its own slug - /home on Deskwell - with a canonical tag pointing at itself. Two addresses, byte-identical content, both in sitemap.xml, and Google duly indexed and ranked both: the site's own two entries competed for the same searches and split whatever authority the homepage had between them. The [slug] route now reads the assignment through a cached getHomepageId() and permanentRedirect('/')s when the row it found is the homepage - before the draft check, so an admin following an old link lands on the root where the draft actually renders, and outside any try, since permanentRedirect works by throwing. The slug is also excluded from generateStaticParams (prerendering a page nobody is served) and from app/sitemap.ts, which already lists the bare domain at priority 1. Because [slug] is held indefinitely under revalidate = false, app/api/admin/config/route.ts now calls revalidatePath('/', 'layout') whenever homepageId changes - without it, moving the homepage would leave the old slug redirecting for ever and the new one still serving a copy. Nothing here is Deskwell-specific or configurable: the redirect follows the assignment, whatever the page is called.
app/sitemap.ts escapes every URL for XML on the way out (escapeSitemapEntries, lib/seo/sitemap-xml.ts, core 0.5.1151). Next interpolates each entry's url into <loc>${item.url}</loc> unescaped, so a single raw & from a query string is read as the start of an entity reference and the parser stops dead at that line - which loses every entry below it, not merely that one. Escaping is done once in that funnel rather than per module; the corollary is that a module's getPublicSitemapEntries must hand its URLs over raw, since anything pre-escaped comes out double-escaped.
All three scans are deliberately independent of publicBasePath (core 0.5.1150 for sitemap/robots, 0.5.1418 for the head hook). What a module puts in sitemap.xml or robots.txt has nothing to do with whether it serves public pages under a prefix of its own: ultimate-seo's admin-managed entries and shop-variations' per-combination product URLs are both about other modules' pages. Until 0.5.1150 the two existsSync checks sat behind the if (!base) continue guard in the generator, so a module without a public base of its own was never collected - the ultimate-seo Sitemap & robots screen saved rules and changed nothing at all.
The generator writes four files, not one, and every split is load-bearing:
-
lib/modules/router.ts-API_ROUTES,PAGE_LOADERS,dispatchModuleApi,resolveModulePage,MODULES_IN_BUILD. Imported only fromapp/cactus-admin/**andapp/api/m/**. -
lib/modules/router.public.ts- everything listed above. Imported by the catch-allapp/(public)/[slug]/[...path]/page.tsx, the feed routes,app/sitemap.tsandapp/robots.txt/route.ts. Never by a layout, and never by the single-segment page. -
lib/modules/router.public-slug.ts(since 2026-09-13) -resolveModuleIndexPage(base)andresolveModuleRootSlugPage(slug), backed by a loader for each module's index page (app/public/<base>/page.tsx) and the bare-slug claims, and nothing deeper. Imported only byapp/(public)/[slug]/page.tsx, which serves every info page, product page and filter landing page. A single path segment can only ever be a module index or a claimed slug, so that route has no business reaching the basket, checkout, account or portal pages; while it importedrouter.public.tsit carried all of their client code.router.public.tsre-exportsresolveModuleRootSlugPagefrom here. -
lib/modules/public-head.ts(since 2026-09-13) -collectModulePublicHead(siteUrl)alone, imported byapp/(public)/layout.tsx. The layout used to import it fromrouter.public.ts, which put every module public page - the space planner and its three.js scene, the cart, checkout, the purchase-order portal - in the chunk group of every page on the site.router.public.tsre-exports it.
All four are gitignored and rewritten on every build and dev start.
The reason is that Next.js ships a client component to the browser if a route's server graph can reach it at all, rendered or not, and a lazy () => import(...) is an edge it follows. Only import type and a real next/dynamic boundary cost nothing. While both halves shared one file, app/(public)/[slug]/page.tsx - the route behind every info page, category and product - could reach PAGE_LOADERS, and therefore every module's admin screens, and therefore the Puck editor those screens import along with its 118 client blocks. Measured on a live site in August 2026: 5.5 MB of uncompressed JavaScript on a category listing, of which about 2.2 MB was admin code the page never renders. Splitting the tables took the same route to 3.0 MB.
Nothing in the toolchain notices this on its own. tsc and eslint see nothing wrong, the build succeeds, and the only symptom is weight. Two things guard it instead:
-
lib/modules/router-split.test.tsfails if the public half gains an admin loader, if it imports the admin half, or if any file underapp/(public)(plussitemap.ts,robots.ts,not-found.tsx) imports@/lib/modules/routeror the full@/lib/modules/extension-pointsmap. It scans the tree rather than checking a hand-kept list. It also fails if a layout or[slug]/page.tsximportsrouter.public.ts, ifpublic-head.tsloads anything but a module'slib/head, or ifrouter.public-slug.tsloads anything deeper than an index page, a root-slug page or alib/root-slugclaim. -
lib/modules/public-graph-weight.test.tswalks the layout, the homepage and both[slug]routes, followingimport()wherever the importing file is server code (Next does), and fails when a heavy library (three,maplibre-gl,@react-three/*,chart.js) is reachable from that eager graph, or when client code statically imports a Node built-in that only a large browser polyfill can satisfy (crypto,stream,buffer). Thecryptocase is real:quote-for-shop's Retrieve Quote button reached acryptoimport through a shared helper and put about 121 KB gzip of polyfill on every shop page until September 2026. -
node scripts/analyse-public-bundle.mjswalks the same graph from a route entry and prints client-component weight by module, without needing a build.--why modules/shopprints the shortest import trail in;--offenderslists files a public request can reach that still import the full extension-point map.
The same rule is why lib/modules/extension-points.public.ts exists alongside lib/modules/extension-points.ts. Anything a public request can reach must import the public map.
Core resolves requests to a module's public base through three routes, in order:
-
app/(public)/[slug]/page.tsx- the existing InfoPage-by-slug route. It looks up anInfoPagefirst (InfoPage always wins a slug collision); on a miss, it falls back toresolveModulePublicPage(slug, [])for the module's index page, and then toresolveModuleRootSlugPage(slug)for a module claiming the bare slug for content of its own. -
app/(public)/[slug]/[...path]/page.tsx- a generic catch-all for a module's sub-pages (/<base>/<...path>), added specifically for this mechanism. -
app/(public)/[slug]/feed.xml/route.ts- a dedicated literal-segment delegate todispatchModulePublicRoute(slug, ['feed.xml'], 'GET', req), since aroute.tscan't share a folder with the[...path]page catch-all.
All three are force-dynamic. The index fallback in particular calls getSessionFromCookie() (a dynamic API) before rendering the module component - without that, the route would be cached forever under the InfoPage route's revalidate = false after its first render, and content like Gazette's scheduled posts would never go live on time. This was the riskiest part of the mechanism to get right; it's covered by an explicit build-time acceptance check (/gazette renders fresh on each request) rather than relying on convention alone.
A module can also put its own content on a bare slug - /my-post rather than /gazette/my-post - by declaring publicRootSlug in its manifest:
"publicRootSlug": {
"page": "./app/root/[slug]/page",
"claimImport": "./lib/root-slug",
"claimExport": "gazetteClaimsRootSlug"
}claimExport names a (slug: string) => Promise<boolean>; page names a page component receiving params: { slug }. generate-module-router.mjs emits both as lazy () => import(...) loaders into PUBLIC_ROOT_SLUG_CLAIMS, for the same reason every other loader in that file is lazy: app/(public)/[slug]/page.tsx imports the router statically, so an eager import would pull the claiming module into the public bundle.
Precedence is fixed and deliberate: InfoPage, then module index, then claim. Core content always wins, and the claim is only consulted on what would otherwise have been a 404 - so the cost on a genuine 404 is one claim call per declaring module. A claim that returns false (the gazette's does whenever the site is on the default URL style) short-circuits after a single settings read.
The page must live outside app/public/<base>/, since everything under that directory is also mounted at /<base>/.... Gazette keeps its root-slug page at app/root/[slug]/page.tsx and leaves the app/public/gazette/[slug]/page.tsx in place as a permanentRedirect to the new address, so URLs indexed under the old shape keep working.
Unlike publicBasePath there is no uniqueness check to make: two modules may both declare a claim, and they are asked in registry order until one says yes. A module whose claimed slug also matches an InfoPage or another module's base simply never gets asked - so a claiming module should keep its own slugs clear of those (gazette's ensureUniquePostSlug reads getInstalledPublicBasePaths() and the InfoPage slugs and numbers the post's slug rather than letting it be shadowed).
publicBasePath uniqueness is enforced twice: at build time (generate-module-router.mjs fails the build if two modules declare the same base) and at module-install time (POST /api/admin/modules rejects a colliding base, and also rejects one that matches an existing InfoPage slug). The reverse direction is enforced in the pages API: creating or renaming an InfoPage to a slug reserved by an installed module's publicBasePath (via lib/modules/public.ts's getInstalledPublicBasePaths()) returns 409.
Core owns two generic pieces here. Neither names any module, and both earn their place on a site that installs nothing.
core.inbox-tabs lets a module publish a panel into the Inbox page, which is right for a module's own screen and useless to anybody else: core is holding a React component, so it cannot merge two of them, count them, sort them or search them.
core.conversation-provider publishes the same conversations as plain data instead. The contract is in lib/conversations/types.ts - ConversationSummary, ConversationMessage, ConversationProvider, a channel of email/chat/form/phone/sms, a capabilities object, and optional send, markRead and byIdentity functions. resolveConversationProviders(user) in lib/conversations/providers.ts reads installed modules' manifests through the same INSTALLED_MODULE_WHERE gate and permission filter as resolveExtensionTabs, shape-checks each entry so a module publishing a component here is skipped rather than exploding inside the page, and ignores an entry the generated registry has not caught up with yet.
ConversationSummary.href is admin-root relative (inbox?tab=contact-form&id=42): only the rendering page knows what this site calls its admin path, and anything trying to be an absolute URL is dropped.
Core's own All tab (components/admin/InboxAllPanel.tsx, ALL_TAB_ID = 'core-all', first in the strip) renders when two or more providers resolve for that user: a read-only merged list, newest first, each row deep-linking into the owning module. One provider failing costs that channel only. This is what a site running just the contact form and live chat gains from the seam without installing anything else.
Suppression. A module may declare consumesConversationProviders: true in its manifest, meaning it presents every channel itself. Core then hides its own All tab and the core.inbox-tabs entry of every module that publishes a provider. Two properties matter more than the rule:
-
It is resolved per user, never globally. The consumer's own tab goes through the permission filter first, and if it did not survive for this user then nothing is suppressed for this user either. Otherwise a colleague holding
contact.viewand not the consumer's permission opens the Inbox one morning to find their messages gone and no tab to click. -
It fails safe on a missing flag.
Module.manifestis rewritten from the deployedcactus.module.jsonat build time, so a freshly installed consumer's stored copy may not carry the key yet. A missing key hides nothing.
resolveInboxTabs(user) in lib/conversations/inbox-tabs.ts returns { tabs, showAllTab } and is what the Inbox page calls; ExtensionTab carries moduleName so a caller can tell whose tab it is holding.
serverOnly on an extension entry. scripts/generate-module-extension-points.mjs decides what reaches the client-visible extension-points.public.ts by one rule: is the file under components/admin/. That is right for a React panel and wrong for a plain function. A conversation provider lives in its module's lib/, so by that rule it was public, and its graph - a mail client's IMAP library, a telephony SDK - became reachable from a public page. Any manifest entry may now say serverOnly: true and is withheld wherever its file sits.
core.conversation-provider publishes what has already been said. It has no way to start a conversation, and there is a real difference between a screen that shows a customer's number and a screen that rings it.
core.dialler is the other half: the site's own numbers to call out from, and one method that places a call. The contract is in lib/dialler/types.ts - DiallerNumber, DialRequest, DialResult, Dialler - and lib/dialler/registry.ts resolves it through the same INSTALLED_MODULE_WHERE gate, permission filter and shape check as the conversation providers. Core dials nothing itself and names no provider, exactly as it names none for text messages.
Two-leg by design. DialRequest carries callMeAt as well as to and from, because nothing here dials a customer and hopes somebody is holding the phone: the site rings whoever pressed the button first, says who is about to be rung, and connects the two once they answer. A provider that worked any other way would be placing calls into an empty room.
A refusal is a value, not an exception. DialResult is { ok: true } or { ok: false, reason }, because "that number is not on the account" is a sentence for the person at the keyboard rather than a stack trace.
Two functions, and which one to call matters:
-
firstDialler(user)answers "is there a dialler at all" out of the database. Cheap. This is what a screen deciding whether to draw a menu entry must ask. -
callerNumbers(user)answers "what can it call from", which reaches a telephony API over the network. This belongs to the screen that opens, where somebody is waiting for an answer anyway. An empty list means there is nothing to place a call with - no module, no credentials, or no number - three causes with one answer as far as a screen is concerned.
Set serverOnly: true on the entry: a dialler carries telephony credentials and must never reach a browser bundle. Twilio publishes the only one today (twilio-dialler, modules/twilio/lib/dialler.ts), wrapping the same click-to-dial its own settings screen uses.
core.conversation-provider publishes what was said and core.dialler starts a new conversation. Neither helps the third case: somebody is looking at a conversation they hold, in a reply box, with nothing typed in it yet.
core.reply-suggestions is the seam for that. A module that can write publishes a ReplySuggester (lib/conversations/types.ts): label, isConfigured() and suggest({ messages, subject, authorName, count }), answering with plain-text drafts. lib/conversations/reply-suggestions.ts resolves it through the same INSTALLED_MODULE_WHERE gate and shape check as every other data point here.
Neither end knows the other. The module holding the conversation hands over a list of messages and never learns which model answered; the module that answered never learns whether the words came from a mailbox, a contact form or a chat widget. Core learns no module name at either end, and a site with nothing installed here simply never draws the button.
A message carries a role, not a direction - them, us or note. A note is a colleague talking to a colleague: it is context worth having and must never be quoted back at a customer, which is a distinction in/out cannot make.
Three functions, and which one to call matters:
-
canSuggestReplies()answers "could anything here draft one" and is what a screen deciding whether to draw the button asks. It costs nothing at all on a site with no suggester installed - the generated registry is empty for the point and the function returns before it reaches the database - andisConfigured()is cheap by contract, so a site with one pays for a settings row rather than a round trip to anybody's API. -
suggestReplies({ messages, subject, authorName })is what the host's own route calls. It trims the transcript to the newest end (trimTranscript, 24,000 characters, always keeping at least one message), calls the first configured suggester, and refuses in English rather than crashing. -
ReplySuggestionErrorlives inlib/conversations/reply-suggestion-error.ts- its own file, with no imports at all, because both ends need it: the types file beside it must stay free of runtime code, and the resolver cannot be imported by a module the registry imports.isReplySuggestionErrormatches by name and shape as well as byinstanceof, since the same class can be evaluated twice across a bundle boundary.
The UI half is core's too. components/admin/ReplySuggestions.tsx is the button, the three drafts and the tick/cross, and it deliberately owns none of the writing box: clicking a draft calls onPreview(text) and the cross calls onPreview(null), so a host with a contentEditable box, a markdown box or a plain textarea each keeps its own. The host stashes what was there on the first preview and puts it back on null, which is what makes flicking between three drafts and then changing your mind restore what somebody actually wrote.
Set serverOnly: true on the entry: a suggester reads a decrypted API key. google-ai-studio publishes the only one today (lib/reply-suggestions.ts); unified-inbox and contact-form consume it, each from a route of its own that turns its own messages into plain words.
EmailLog is a delivery ledger: one row per recipient per send, written by sendEmail() on success and on failure. It holds the address, cc list, subject, template key, which module asked, the status and error, the Message-ID we set, the provider's own id, and the time. It never holds a body. An archive would grow without limit and have to be pruned to keep the site alive, which is a worse failure than not having one.
It exists because the site's mail is sent by Brevo or by SMTP and never touches the owner's own Sent folder, so no mail client can be asked "did the order confirmation actually go?". A logging failure never takes the email down with it. sendTemplateEmail fills the template key itself and derives the module name from the key's own namespace, so no call site changed.
EmailPayload also carries three optional extras a module may pass: headers (set your own Message-ID and a reply weeks later can be matched back to what prompted it), from ({name?, address}, for a module answering on behalf of more than one address) and transport (a Brevo key or SMTP details for one send). All three are logged like anything else, and a payload carrying its own transport satisfies the isEmailConfigured() guard, since credentials being tested before they are saved are exactly the case where the environment is still empty.
Retention is SiteConfig.emailLogRetentionMonths (default 12), swept by GET /api/cron/email-log/purge at 0 5 * * *. There is no admin box for the number and no screen showing the log; the ledger is read by whatever wants to show a person's history.
The site registers exactly one cron job with Vercel: /api/cron/dispatch, declared in a committed vercel.json at the repository root. Everything else core or a module wants scheduled hangs off that single tick. The tick is every half hour by default (*/30 * * * *, hourly until September 2026) and gets faster on its own if any job on the site is set to run more often than that - see Choosing how often a job runs below.
Half-hourly rather than hourly because the dispatcher's budget is a single tick, and three things followed from an hourly one: a job deferred for want of time waited a full hour for another go; a job's minute field was decorative, since :00 was the only minute the dispatcher was ever awake for; and a daily job, having waited longest of anything, sorted to the front of the next busy tick. Halving the tick halves the first two and softens the third. It does not make anything run twice: a job is due only where its expression matched inside (lastRunAt, now], and lastRunAt is stamped past the match it has just consumed, so an hourly job stays hourly - at worst half an hour later than the minute it names.
vercel.json has to be committed rather than generated. Vercel reads it when it creates a deployment, out of the pushed commit - so a vercel.json written during npm run build is written after the only moment anything would have read it. Core writes the file into an install's own repository as part of the core update (lib/updates/core.ts) and the module registry sync (lib/modules/github.ts), which is a one-off repair on the first sync after this shipped and a no-op afterwards. isSkippedCorePath keeps the generic core reconcile off it, so an upstream tag can never delete the install's copy.
There is one entry rather than one per job because Vercel's Hobby plan allows two cron jobs per project. Core plus the shipped modules declare more than twenty, so a per-job list would stop a Hobby site deploying at all, and choosing two of them on the owner's behalf is not a decision core can make.
app/api/cron/dispatch/route.ts is the dispatcher. On each tick it:
- Assembles the job list - core's own six (
CORE_CRON_JOBSinlib/cron/jobs.ts, including/api/cron/media/retired-blobsat15 * * * *, which deletes superseded media blobs once their cache hold has passed - seelib/media/retired-blobs.ts) plus every installed module's manifestcronJobs, read fromModule.manifest, whichscripts/sync-module-manifests.mjsrewrites from the deployedcactus.module.jsonon every build. Modules infailedorinactivestatus are excluded, as they are frommodules.json. A manifest entry pointing outside/api/m/<module-name>/, or carrying a schedule core cannot evaluate, is logged and skipped - it is never scheduled and never silently half-scheduled. - Asks, per job, whether its expression would have fired since that job's
CronRun.lastRunAt(firedBetweeninlib/cron/schedule.ts, a five-field UTC matcher supporting*, numbers, ranges, lists and steps). Asking about the window rather than the current minute is what gives the same answer whether the dispatcher is woken hourly on a paid plan or once a day on Hobby. The lookback is capped at eight days, so a job missed for a month runs once, not thirty times. - Runs the due jobs longest-waiting first, calling each with
Authorization: Bearer $CRON_SECRET- the same header Vercel used to send, so no core or module cron route needed changing.lastRunAtis stamped before the call, so a job that hangs costs the ones behind it one tick rather than every tick for ever. The dispatcher stays inside Vercel's HobbymaxDurationceiling of 60s and defers whatever it cannot reach to the next tick. - Records the outcome on
CronRun(lastStatus,lastError) and prunes rows for jobs that no longer exist.
A job's time limit is the tick, not a fixed slice. There was a 25-second per-job cap until September 2026, and it was a mistake: module routes are given 60s by the shared module dispatcher, several are written to use most of it, and every one of those was cut off and recorded as a failure it could do nothing about - the weekly SEO audit failed that way every week. A job now gets whatever is left of the tick, and is deferred untouched if fewer than five seconds remain. Fairness comes from the ordering rather than from a cap: a job is stamped before it is called, so a slow one sorts last next time and everything it delayed goes first.
What lastError holds. For a job that answered with an HTTP error, the recorded status is HTTP <code> followed by the route's own error or message field where it sent one, trimmed to 300 characters. That suffix is only as good as the route's error handling: an uncaught throw is masked by the framework into a bare "Internal Server Error" before the dispatcher ever sees it, so a cron route that wants to be diagnosable has to catch its own failures and say what went wrong.
A job seen for the first time is recorded, not run: installing a module does not fire its cron immediately, and the first tick after this shipped does not fire every job on the site at once. Each starts on its own schedule from the following tick.
The practical cost is that a schedule is honoured to the tick rather than to the minute - on an hourly tick, 30 6 * * * runs at the 07:00 tick, and on Hobby once a day, which is all Hobby ever allowed.
Settings โ General โ Schedules lists every job this site runs on a timer, grouped by what owns it, each with a dropdown: every minute, 5, 10, 15, 20 or 30 minutes, every hour, every 3, 6 or 12 hours, or once a day. The first option is always Normal, which is whatever the job's author declared - a weekly SEO audit stays weekly unless somebody deliberately changes it. The permission is config.manage, the same key that guards the navigation editor.
Run now. Every row on that page has a Run now button, which calls the job this instant and waits for it. POST /api/admin/cron/run (config.manage) takes the job's path, refuses anything not in this install's declared list, and goes through the same callCronJob() helper (lib/cron/run.ts) the dispatcher uses - so a job run by hand is the same job run the same way, with the same bearer token and the same reading of whatever it answered. It is recorded the same way too: CronRun.lastRunAt is stamped before the call, exactly as on a tick, and lastStatus/lastError are written from the result, so Last run tells the truth about the last time the job happened rather than the last time a timer said so.
Running a job early does not move its schedule forwards, but it does reset the clock: the stamp is what the dispatcher reads to decide when the job is next due, so a nightly job run at four in the afternoon is next due the following night. That is what a button marked Run now should do, and it is also why a second press is refused while the first is still going - one job at a time, per page.
A module hosting the frequency dropdown on its own settings tab can host the button too: runCronJob() and describeRunResult() are exported from components/admin/CronScheduleField.tsx alongside saveCronFrequency().
What is stored is the choice ('15m'), never a cron expression - CronSchedule, keyed by the job's path, no row meaning "as its author set it". The expression is rebuilt from the job's current declared schedule each time it is read (lib/cron/frequencies.ts), which is what makes a module update safe: a module that moves its nightly job from 03:40 to 04:10 takes the owner's "once a day" with it. The minute and hour the author chose are the anchor and are carried into the new frequency, so 40 3 * * * set to "every 6 hours" becomes 40 3,9,15,21 * * * rather than being dragged to midnight.
The tick follows the jobs. resolveDispatchSchedule() (lib/cron/jobs.ts) measures every effective schedule with minIntervalMinutes() and dispatchScheduleForInterval() picks the slowest of */1, */5, */10, */15, */20, */30 that is still at least as frequent as the fastest job, falling back to the half-hourly default. Rounding is always down: a seven-minute job gets a five-minute tick, because checking sooner than needed costs almost nothing and checking later means the job does not run when it was asked to.
Because Vercel reads vercel.json when it creates a deployment, a change that moves the tick is not live on save - it needs a commit and a build. PATCH /api/admin/cron calls syncVercelJson() (lib/modules/github.ts) only when the tick actually moves; that writes the one file to the install's repository with [cactus-deploy] in the message, which triggers the build. The response says whether a deploy was started (deploy: 'triggered' | 'not-needed' | 'unavailable'), and a site with no GitHub App connected keeps the setting and picks the new tick up at its next update rather than losing the change.
Costs are worth knowing before setting anything to every minute. On Vercel's fluid compute, Active CPU billing pauses during I/O but provisioned memory does not, and the dispatcher stays alive awaiting the job's own response - so a minute-by-minute job bills for both instances for its whole wall-clock duration. A tick that finds nothing due is a fraction of a second and costs pennies a month; a job that runs for twenty-odd seconds every minute is a different proposition.
Info pages (InfoPage model) always use the Puck builder. bodyFormat is always 'builder' for new pages - the admin UI offers no markdown option. Legacy rows with bodyFormat: 'markdown' are auto-migrated to 'builder' the first time they are opened in the admin editor (a PATCH is sent in the background; the public render still falls back to the markdown pipeline for any rows that haven't been migrated yet).
-
builder: content stored inbuilderData(JSON), rendered via Puck's<Render>component (@puckeditor/core/rsc). -
markdown(legacy): content stored inbody, rendered through the sanitized-markdown pipeline (marked+DOMPurify). No new pages are created in this mode.
The Puck editor (@puckeditor/core) is lazy-loaded - it ships no bundle to any route that isn't the specific page-edit admin screen. The editor is mounted with the full component config (lib/puck/config.tsx) extended with custom field renderers (media pickers, menu selector).
All blocks are defined in lib/puck/config.core.tsx and are safe for server-side rendering (no hooks, no browser APIs). lib/puck/config.tsx is a thin wrapper over it that merges in the installed modules' blocks and re-exports the rest unchanged, so @/lib/puck/config still means what it always did to editors and to module code.
Why that split exists. lib/puck/module-components.ts statically imports all 118 module block client components. config.rsc.tsx is what every public page renders through, and while it reached the module map - via the config file it imports for core's own blocks - every visitor to every page downloaded every installed module's client code. On one live site that measured 690KB of compressed JavaScript on a homepage showing eleven images, carrying 3D viewers, abandoned-cart trackers and product checks that the page never rendered. None of it was ever used on a published page: every module block ships an RSC half and config.rsc.tsx overrides all of them, so the client copies were dead weight held alive purely by the import.
So the rule is: config.core.tsx must never import module-components.ts, and config.rsc.tsx imports config.core, never config. It is not left to tree-shaking - lib/puck/config.core.test.ts walks the real import graph (static imports, re-exports and dynamic import(); import type is erased and not followed) and fails if the map becomes reachable from the RSC config, the public layout or renderInfoPage. It also asserts the edge is still there from config.tsx, since the editor genuinely needs all 118 blocks and a walker that had quietly stopped resolving would otherwise pass everything while proving nothing. lib/puck/config-editor-parity.test.ts guards the other half: that the merge still registers every block and still wraps each one in withResponsiveVisibility. Most blocks expose a Padding (left/right) field so editors can add breathing room without needing an extra Spacer block.
Block padding is horizontal-only - it acts as a left/right gutter so content doesn't run to the page edges, without stacking vertical gaps on top of each block's own margins. The field's default option, Default (site spacing), resolves to var(--block-padding, 1.5rem), i.e. the site-wide gutter set in Styles โ Spacing & Breakpoints (themeStyle.spacing.blockPadding, emitted by buildTokenStyles). Content-flow blocks (Heading, Text, Rich Text, Image, Video, Embed, Quote, Button, Badge, Accordion, Feature List, Stats, Logos, Social Links, and the Contact Form module block) default to this gutter so a bare page never touches the edges. Self-padded or full-bleed blocks (Hero, CTA Banner, Callout, Card) and structural containers (Section, Grid, Group, Split) default to None, as does chrome reused inside the header/footer roots (those roots already apply their own 1.5rem gutter, so noGutterDefault() resets the reused blocks to avoid doubling up). getPadding() maps default/unset โ the gutter var, none โ 0, and sm/md/lg/xl โ 0 0.5remโฆ0 4rem.
Blocks that paint their own box (Section, CTA Banner) carry a separate paddingY field for the vertical half, keyed to the shared PADDING_Y_MAP scale (none 0 / sm 2rem / md 4rem / lg 6rem / xl 10rem) so "Medium" is the same height of breathing room wherever it is picked. These are emitted per block instance (responsiveMediaCssFor on a data-* id, desktop inline as the base) rather than as utility classes, and always as the padding-top/padding-bottom longhands - the shorthand would wipe the horizontal padding the cactus-pad-* classes provide.
Responsive breakpoints: Grid and Split render fixed CSS grid templates inline (e.g. repeat(3, 1fr)), which don't reflow on their own. buildTokenStyles also emits two @media rules, driven by Styles โ Spacing & Breakpoints โ Tablet/Mobile breakpoint (themeStyle.spacing.tabletBreakpoint/mobileBreakpoint, default 1024px/640px), that override those templates with !important below each width: under the mobile breakpoint, any .puck-grid/.puck-split collapses to a single column; between mobile and tablet, a .puck-grid with 3 or 4 columns (matched via its data-cols attribute) drops to 2. These two values can't be exposed as ordinary var()-based tokens the way colours/fonts are - a @media width can't read a CSS custom property - so buildTokenStyles always bakes literal pixel values into the rule, falling back to 1024px/640px even on a fresh install that has never saved a Styles page. Since the same generated stylesheet is injected on the public frontend and into both the Pages and Layouts Puck editor canvases, resizing the editor's viewport preview (Small/Medium/Large in the Puck toolbar) shows the same stacking behaviour live.
Blocks are organised into categories that appear as collapsible groups in the Puck left panel.
| Block | Purpose |
|---|---|
| Section | Full-width container with background (colour, gradient, image, or a decorative "Grid + scan beam" preset - a faint graph-paper grid with a looping light sweep, useful as an ambient backdrop panel), vertical padding, max-width, content vertical alignment (top / middle / bottom, per breakpoint - takes effect wherever the section is taller than its content, e.g. a "Full view height" section), sticky positioning, border, box-shadow, and AOS scroll animation controls. Content rendered via an inline slot (content prop). |
| Grid | CSS grid container (2-4 columns) with configurable gap, padding, column-width ratios (30/70, 40/60, etc.), per-column horizontal alignment, vertical alignment across all cells, per-column vertical alignment (col1VAlign-col4VAlign, blank = follow the grid, emitted as align-self on that column's track; a sticky column keeps its own align-self: start), and space below. Each column (col1-col4) is an inline slot. Renders with a puck-grid class and data-cols attribute - see Responsive breakpoints below. |
| Group | Flexbox container with direction (row / column), justify-content, align-items, wrap, gap, and padding controls. Children rendered via an inline slot (items prop). Replaces the old Flex and Row blocks. Available in all configs. Already responsive via flexWrap: 'wrap', so it isn't part of the breakpoint stacking below. |
| Split (retired) | Two-column layout (50/50, 60/40, 40/60, 70/30, 30/70, plus auto) using renderDropZone - each column is a live Puck drop zone backed by data.zones. auto renders grid-template-columns: fit-content(60%) 1fr: the left track sizes to whatever width its content declares and the right takes the slack, capped at 60% so the right column can't be starved. Only content with a definite width has anything to hug - ordinary prose has no natural width and just takes the 60%. Shows an 80 px placeholder when empty so editors can always see and drag into the column. Renders with a puck-split class - see Responsive breakpoints below. Order once stacked (stackOrder, added 2026-08-23): left-first (default) or right-first - on phones, where the split has already collapsed to one column, right-first emits a mobile-only rule giving the left zone order: 2 and the right order: 1, so the right-hand column lands on top. Desktop and tablet are untouched. Used by the shop's Two Column checkout starter so the checkout steps sit above the order summary on a phone rather than below it.Retired 2026-08-15, the same way the dynamic Grid was: still registered in every config it was in, so existing data renders and edits unchanged, but removed from all three categories pickers (puckConfig, footerPuckConfig, layoutPuckConfig) and named in the disallow list of every core slot field (Section content, Grid/Grid2-4 col{n}, Group items, MemberGate/TrustedMemberGate content). A Split cannot render inside a slot. data.zones only reaches a block through puck.renderDropZone, and Puck's RSC slot renderer (SlotRender in @puckeditor/core) doesn't pass it - the client editor's Item does, so a Split inside a Section looked right in the editor and published two empty columns. That is what blanked every Gazette post body on 2026-08-15. Use Grid2 (columnSizes: '70-30' and friends) for anything new; it is slot-based and works at any nesting depth.Removing a block from a categories picker is not the same as leaving it registered. getModuleLayoutSharedParts() is the one place that builds a component set from the category lists rather than from puckConfig.components, so a name dropped from a picker also vanishes from every module-declared layout type - and Puck renders nothing at all for a type its config doesn't know. That took the whole two-column half off every live shop product page and the checkout on 2026-08-15, hours after the retirement above. Retired-but-registered blocks are therefore listed in RETIRED_BLOCKS (Split, Grid) and merged back into sharedComponents only, never into sharedCategories. lib/puck/module-layout-blocks.test.ts holds the line: every core block used by a module's starter layouts must be registered in that layout type's config. |
| Spacer (displayed as "Space") | Fixed vertical gap (8 px - 96 px) |
| Divider | Horizontal rule - solid, dashed, or dotted; thin / medium / thick |
| Block | Purpose |
|---|---|
| Heading | Standalone heading (Display, H2โH5) with alignment, colour, and padding. Colour is any site-palette swatch, a custom picked/typed colour, or left to the level's own default. Give it a block height (Small/Medium/Large/Full screen) and a vertical position (Top/Middle/Bottom) to sit the text centred within a tall block. Add a Link URL to make the whole heading clickable, with an optional hover underline in a colour of your choosing. "Display" is the largest level, above H1 - for homepage heroes/campaign banners, styled from the Styles โ Headings โ Display tokens and rendered as a real <h1> tag (builder-format info pages don't auto-inject their own page-title H1). Optional "Reveal animation" (stagger-lines) splits the text on line breaks and animates each line rising into place on load, staggered - independent of the separate scroll-triggered animation controls on the same block. Keep on one line (fitOneLine, off by default) scales the text down just enough to keep it on a single line when it would otherwise wrap: the same measure-and-transform trick the Menu block uses, so it only ever shrinks and re-measures on resize and on late web fonts. The heading's box height comes down with the text, so a shrunk heading leaves no gap of its original size behind. |
| TextBlock (displayed as "Text") | Paragraph text with left / centre / right alignment |
| RichTextBlock (displayed as "Rich Text") | Full WYSIWYG rich text editor (bold, italic, lists, blockquote, links); stores HTML. Text colour (textColor, SiteColourField - palette swatch, colour picker, or typed CSS colour) recolours the block's text via a scoped .puck-richtext[data-richtext-id="<id>"] p,ul,ol,li,h1..h6,blockquote{color:โฆ} rule (richTextColourCss, exported from config.tsx so config.rsc.tsx's published render emits identical CSS) - a stylesheet rule, not an inline style, because the globals.css .puck-richtext โฆ rules set explicit descendant colours a wrapper style couldn't cascade past; links stay on --color-primary deliberately. There is no in-editor colour toolbar: the richtext field is Puck's built-in and its toolbar can't be extended without forking, so colour is a block setting instead |
| Quote | Styled blockquote with optional attribution and an optional photo to its left (media picker; size + circle/rounded/square shape) |
| Caption | Small text styled from the Styles โ Fonts & Typography โ Caption tokens - for labels, footnotes, or small print anywhere on a page, not just form-field labels |
| Block | Purpose |
|---|---|
| ButtonLink (displayed as "Button") | Standalone button link - primary, secondary, outline, or custom style; per-breakpoint Alignment (blank = inherit the container's alignment). Custom reveals per-block button/text/hover/hover-text colours (palette swatch, native colour picker, or any typed CSS colour) and a Border width (none / 1-4px) that in turn reveals a border colour. resolveFields keeps all of it hidden unless it applies |
| CTABanner | Call-to-action banner - white / light-gray / brand background; Vertical padding (top/bottom) per breakpoint (paddingY, sharing PADDING_Y_MAP with Section) since the shared padding field is horizontal-only |
| Block | Purpose |
|---|---|
| ImageBlock (displayed as "Image") | Full-width image with alt text and optional caption |
| VideoEmbed (displayed as "Video") | YouTube or Vimeo embed (paste the watch URL; 16:9 / 4:3 / 1:1) |
| Embed | Generic <iframe> embed (maps, forms, etc.) |
Both of those put an editor-supplied address into an <iframe src>, so both are restricted to http: and https: (toEmbedUrl tests the protocol before it matches a provider; Embed runs its src through isHttpUrl()). Anything else - a javascript: or data:text/html address - would execute in the site's own origin, so the block renders its "could not parse" notice instead of a frame. Any future block that frames or links an editor-supplied address should do the same.
| Block | Purpose |
|---|---|
| Hero | Large hero section with heading, sub-heading, and CTA button |
| Eyebrow | Small pill label above a heading, with an optional pulsing dot for a "live" feel |
| Card | Image + heading + body text + optional CTA button; Card height (auto / fixed presets / full screen / fill container, sharing the Heading block's height vocabulary via lib/puck/blockHeight.ts) |
| Callout | Alert/notice box - info, success, warning, or error |
| Badge | Small coloured pill label |
| Trustline (displayed as "Trust Row") | Row of small icon + text reassurance points (checkmark, delivery, shield, clock, star, or price-tag icon) |
| Chip | Small label + value card - sits in the normal flow, or floats over a position: relative parent (e.g. a Section) via a corner preset, for callouts pinned over an image or decorative panel |
| Accordion | Collapsible FAQ using native <details>/<summary> - no JS required |
| FeatureList | List of features with emoji icon, title, and description |
| Stats | Row of statistic items (value + label) |
| Logos | Horizontal strip of logo images with configurable height and alignment |
| Block | Purpose |
|---|---|
| SiteLogo | Site logo or name - auto-reads logoMediaId from SiteConfig; falls back to site name text. Template block. Per-breakpoint Alignment (align, left/centre/right, default left): the logo's <a> is already a block-level flex container, so alignment rides justify-content on it - no wrapper element, and an untouched block renders byte-identically (flex-start is what it already did). Inside a header column the <a> is a flex item, sized to its content, so there is no free space and the field is a no-op there - header placement remains the column's job. Logo image and Logo image in dark mode (imageUrl / imageUrlDark, both media pickers) let one block use a different image from the site logo. Leave the first blank and the block is on the site logo exactly as before; pick one and it replaces the pair, with the dark arm falling back to the light image rather than to the site's dark logo - half an override would otherwise pair a compact mark in light mode with the full lockup in dark. The dark field only appears once a light image is picked. The pair is resolved by siteLogoImages() in lib/puck/siteLogoAlign.ts, shared by both render halves, and pinned by lib/puck/siteLogoImages.test.ts. The intended use is a header holding two logo blocks, one hidden on each breakpoint: full lockup on desktop, mark on phones. |
| MenuBlock (displayed as "Menu") | Navigation menu - pick any menu; horizontal or vertical orientation, configurable spacing, per-breakpoint scale (CSS zoom, so it takes proportionally less room in the header row), "keep on one line" (measures scrollWidth against clientWidth and shrinks the row with a CSS transform until the items fit), nav behaviour (show / hamburger / current-page dropdown) and the alignment of the collapsed menu. The collapsed button's dressing rides three colour fields - navButtonColor / navButtonBackground / navButtonBorderColor, each a SiteColourField with light and dark arms - applied to the hamburger and the dropdown trigger alike; the bars in both read currentColor so they follow the text colour. Blank falls back to var(--color-text) on a transparent ground (with var(--color-border) on the dropdown trigger, none on the hamburger), which is what both drew before the fields existed. resolveFields hides all three unless some breakpoint actually collapses the menu. Template block. |
| Copyright | Copyright line - auto-renders ยฉ current year + site name from SiteConfig. Template block. |
| LoginButton | Auth-aware login/register buttons - shows "My Account" and "Sign out" when the visitor is logged in. Template block. |
| CookieSettingsLink | A button that re-opens the consent management panel. Calls window.cactusConsent.open(). Place this in your footer so visitors can change their preferences at any time. Template block. |
Blocks marked Template block are most useful in Header/Footer templates but are available everywhere. Blocks that need an image use a custom media-picker field in the editor; the MenuBlock uses a custom menu-selector field (MenuSelectField). These custom renderers are declared in config.tsx as plain fields and overridden with the full custom renderer in PuckEditor.tsx and TemplateEditor.tsx.
The admin left sidebar is collapsible. Clicking the โน / โบ toggle button collapses it to icon-only mode (56 px wide), freeing horizontal space. The preference is persisted in localStorage. The sidebar auto-collapses whenever a page or template editor is opened, so the Puck canvas always has maximum width on load, and whenever the Inbox is opened, since that screen is a three-column mail reader and the rail buys it back a column on a laptop. Leaving either screen expands it again; the predicate is isSidebarRailRoute() in lib/nav/sidebar-rail-routes.ts (Puck editors plus any route ending /inbox), and a manual click of the toggle while on one of those screens stands - the shell stops treating the collapse as its own and leaves the sidebar as you left it. The footer holds the theme toggle, a My Account link (to /{adminPath}/account), and Sign out, in that order. The theme toggle stays available when collapsed: it becomes a single round button showing the active mode's icon that cycles Light โ Auto โ Dark โ Light on each click.
Section order top to bottom: Dashboard, then Inbox if any installed module publishes into core.inbox-tabs, then any ungrouped module nav links rendered inline as plain links with no heading (modules with no navGroupLabel - since the 2026-08-10 tidy that is most of them: Gazette, Boards, SEO, Space Planner and the rest), then Content, People, System, then any labelled module nav groups (modules that set navGroupLabel - only Shop still does, for its Catalogue and Trading links).
One link per module (2026-08-10). With every module installed the rail used to carry 48 links; it now carries 17 - core's own ten and seven from modules, down from thirty-nine. Each module registers a single navEntries link where its surfaces are related and makes everything else a tab on that page; anything that is really configuration lives as a tab under Settings instead; and a screen belonging inside somebody else's page publishes into that page's tab extension point and registers no link at all (core.inbox-tabs, core.media-tabs, shop.products-tabs, shop.orders-tabs). Nothing was removed in the process - every retired route still exists and redirects to wherever its screen now lives, so bookmarks and older links keep working. Inbox is the first conditional core nav item: it is dropped from the sidebar and from the Settings โ General โ Admin Menu editor alike unless something fills it, since it is a host with no content of its own.
Favourites stay on the icon rail. The pinned block at the top of the nav holds the toolbar (filter box, Newโฆ, expand/collapse-all) and the Favourites list, and it sticks while the rest of the tree scrolls under it. Collapsing the rail drops the toolbar - there is nowhere to put a filter box 56 px wide - but keeps the favourites, drawn as icons in the same order, holding the top of the rail above the first section divider. The Favourites heading is not drawn when collapsed (a word with nowhere to sit), so the block's own bottom border is what marks where the pinned shortcuts end; the star button that pins and unpins an item is likewise expanded-only. A favourite appears twice, once pinned and once in its own section, in both states.
Links can be dragged into any order (2026-09-10). Press on a link, move, and it lifts: a line shows where it will land, and letting go puts it there. A link can be dropped anywhere in the tree, including into a different section - onto a section heading, or onto the dashed strip a section shows once you have emptied it, which is how a link gets into a section that is collapsed or has nothing left in it. Dropping onto a collapsed heading opens that section, so the link is never seen to vanish. Favourites reorder the same way among themselves; the two lists are separate drag areas, because a link joins or leaves Favourites by its star and nothing else. Filter results are a search list and are never reorderable.
The arrangement is per person, held in that browser, not a site setting: localStorage key cactus-sidebar-item-order, a map of section id to the ids of the links in it. Settings โ General โ Admin Menu still owns the shared, permissioned ordering that everyone starts from, and one person shuffling their own rail moves nobody else's. The server still decides which sections and links exist and who may see them; the arrangement only re-orders what it sent, drops any id it no longer recognises, renders a link once even if a stale arrangement lists it twice, and leaves a link it has never seen - one a newly installed module just added - at the end of its own section. A reset button appears in the toolbar as soon as an arrangement exists and puts the menu back the way the site set it up.
The whole thing runs on pointer events rather than the browser's own drag-and-drop, so the cursor never changes and no ghost of the link is dragged about: the only things that move are the row being carried (dimmed) and the line showing where it will land. Under five pixels of movement is still a click, so links open exactly as before. Touch is left alone - the same vertical gesture scrolls the rail, and stealing it would leave the menu unscrollable on a phone. Keyboard users get the same reach with Alt+Up / Alt+Down on a focused link, which steps it one place and follows it with focus; stepping off the end of a section moves the link into the next one, exactly as a drag would. The rules live in lib/nav/sidebar-order.ts (pure, no React, covered by lib/nav/sidebar-order.test.ts); the pointer half is components/admin/useNavReorder.ts.
Module link icons are SVG fragments. A module's navEntries[].icon is the inside of a 24x24 icon - a bare run of <path>/<circle>/<rect>, no <svg> root - because AdminNav supplies the root and with it the viewBox, the stroke width and currentColor. Core scrubs each one in app/cactus-admin/layout.tsx with sanitizeSvgFragment() (lib/sanitize.ts), which wraps the fragment in an <svg> root so DOMPurify parses it in SVG context, sanitises, then peels the root off; a module that supplies a whole <svg> is unwrapped too. Do not use sanitizeSvg() here - with no root element the markup parses in HTML context, where <path> is an unknown element and the whole icon is dropped. That was the bug: from the day module icons were added until 2026-09-10, every module link fell through to the generic puzzle-piece fallback (NAV_ICONS.modules), because an icon sanitised to '' renders as a plausible-looking default rather than an error. An icon that is not markup at all - an emoji, a name, a URL - also draws the puzzle piece, by design.
Each labelled section (Content, People, System, and any labelled module nav group) is independently collapsible: clicking the section label toggles a chevron and hides/shows that section's links. This is separate from the rail collapse above and only applies when the rail is expanded (icon-only mode always shows every link, ungrouped). Each section's open/closed state is persisted per-label in localStorage, so it's remembered across navigation, refreshes, and later visits. Sections default to open (maximised) until the user collapses one. Ungrouped module links have no heading of their own, so they're never independently collapsible - they hide/show along with Dashboard.
The logo + site name at the top of the sidebar (and the site name in the mobile topbar) is a link to / with target="_blank", so clicking it opens the live frontend in a new tab without losing the admin session.
The magnifying-glass button in the sidebar toolbar - and the Cmd/Ctrl+K shortcut from anywhere in the admin - opens a search box that jumps straight to any part of the admin. It searches three kinds of destination:
- Pages - every item in the sidebar the current role can see (Dashboard, Pages, Media, Modules, and so on), plus their "Newโฆ" shortcuts.
- Settings sections - every tab on the Settings and Appearance screens (General, Email, Media, GDPR & Legal, Integrations, Users, Navigation; Styles' Branding/Colours/Typography/Headings/Buttons/Images/Form fields/Spacing), including the Settings tabs added by installed modules (e.g. Shop, Gazette).
- Individual settings - the specific controls a site owner actually goes looking for: backup, restore, reset database, email provider (Brevo/SMTP), send a test email, storage provider, cookie consent banner, GitHub App, Turnstile, Sentry, roles & permissions, email templates, email wrapper designs, and more.
Results match on plain-English synonyms as well as the exact wording, so "captcha" finds Turnstile, "color" finds Colours, and "sign up" finds Registration. Choosing a result doesn't just open the page - it switches to the right tab and scrolls the exact section into view, giving it a brief highlight so it's easy to spot. Search only ever offers destinations the signed-in role is allowed to open, so it never links someone to a screen they can't reach.
The set of searchable settings is defined centrally, so adding a new setting to the list is a one-line change and the whole thing is covered by an automated test that checks every "jump straight to it" link actually lands on a real section.
Every InfoPage row separates the working draft from the live content:
| Column | Purpose |
|---|---|
builderData |
Working draft. The only target autosave ever writes. What the editor and preview route render. |
publishedData |
The current live content. Only the Publish endpoint writes this. What the public sees when status = published. |
publishedAt |
When the live version was last published. |
publishedById |
Who published the live version (plain ID, no Prisma relation). |
history |
Capped array (max 10) of past published versions, newest first. Shape: { data, title, at, byId }. |
The consequence: editing a published page never touches what is live. Autosave writes only builderData; the slug and the public page keep serving the old content until Publish is clicked.
-
draft- never published;publishedDatais null. -
published- has live content inpublishedData; keeps serving it even while a newer draft is being edited.
- Always writes
builderDataand menu associations. - Never writes
publishedData,publishedAt,publishedById, orstatus. - For draft pages only: reconciles
title,slug,metaDescription,ogImageIdcolumns (nothing is live yet, so updating them is harmless). - For published pages: leaves those columns frozen to the published values, so the live page's title, slug, routing, and meta never move on autosave.
Requires pages.publish. On each call:
- Reconciles
title,slug,metaDescription,ogImageIdfrom the editor state. - If
publishedDatais non-null, archives the current live version as{ data, title, at, byId }at the front ofhistory, capped at 10 entries. - Sets
publishedData = builderData,publishedAt = now,publishedById = user.id,status = published. - Calls
revalidatePathfor the old and new slug.
Publishing must always flow through this endpoint. The PATCH route rejects a draft โ published transition with a message pointing at the Publish button.
GET /api/admin/pages/[id]/history returns a lightweight list of published versions, newest first. The live version uses index: "live"; archived entries use their numeric position in the array (index: 0 = most recent archive).
Passing ?index=live or ?index=<n> to the same endpoint returns the full builder data blob for that version.
Restore is non-destructive. "Load into editor" in the version-history panel fetches the chosen version's data, writes it to builderData via an immediate autosave (cancelling any pending debounce first), then reloads the editor. The page stays published; the restored content only goes live when Publish is clicked.
While editing a published page, any title or slug changes you make live in the draft only. The pages list, the browser tab, the public URL, and the site nav all keep showing the published title/slug until the next Publish. This is intentional - live metadata must not move on autosave - but it can surprise an editor who renames a page and sees the old name everywhere. It reads as designed, not a bug.
PATCH /api/admin/pages/[id] can still change the slug of a published page directly (with revalidatePath), bypassing the Publish flow and the history snapshot. Routing stays correct; it is just an unsnapshotted change. Left out of scope for this feature - follow-on work should funnel slug changes through Publish.
InfoPage's real columns (title, slug, status, metaDescription, ogImageId) are canonical. builderData.root.props is a working copy. On every load, root props are overwritten from the DB row. On every save, those four fields are split back out and written to their real columns. This split happens in exactly one server-side location (the save handlers), never client-side.
The public [slug]/page.tsx route branches on bodyFormat. Both branches share the same draft gate (one check at the top). For published pages, the public render uses publishedData (with builderData as a fallback for any pre-backfill rows); for draft pages it uses builderData. Builder pages use <Render config={puckConfig} data={...} /> from @puckeditor/core/rsc - a server component. The editor bundle is never included in the public-page response.
The render logic is shared via lib/puck/renderInfoPage.tsx (renderInfoPageContent), used by both the public slug route and the preview route.
Page content is wrapped inside a layout resolved by resolveThemeLayout('infoPage', { pageId, slug }). The layout is rendered via renderLayoutWithContent(layoutData, pageContent), which patches the Puck config to replace the ContentSlot component's render function with one that returns the real page content. This happens entirely server-side with no hydration overhead.
A login-gated preview route lives at app/(public)/page-preview/[id]/page.tsx, inside the (public) route group so it inherits the site header/footer. It shows the current draft (builderData) with a fixed "Draft preview - not live" info bar, and carries robots: noindex.
Gate: getSessionFromCookie + hasPermission(user, 'pages.read'). Visitors who are not logged-in editors receive notFound() - there is no public token-based access.
The Preview button in the editor toolbar opens /page-preview/${id} in a new tab.
proxy.ts unconditionally passes both /page-preview/ and /layout-preview/ through the site-status gate so a logged-in editor can always reach a preview even when the public site is in coming-soon or maintenance mode.
Cactus has no hardcoded frontend design. All visual aspects are user-configurable through the Layout Builder and Style Guide.
Every layout record has a type field (stored as a plain String on the Layout model, not a database enum, so new types can be added without a migration). The five built-in types are:
| Type | Purpose |
|---|---|
header |
Site-wide header. Rendered above every public page. |
footer |
Site-wide footer. Rendered below every public page. |
infoPage |
Body wrapper for info pages. The ContentSlot block marks where page content appears. |
notFound |
Rendered by app/not-found.tsx when a URL matches no page. |
statusPage |
Rendered by the coming-soon / maintenance status routes. |
Headers and footers are full Layout records edited in Admin โ Layout Builder, not JSON blobs on SiteConfig. The SiteConfig columns headerBuilderData, footerBuilderData, defaultLayoutId, comingSoonPageId, and maintenancePageId were removed.
Each layout can carry a displayConditions JSON field with include and exclude rule lists:
type ConditionRule = { type: ConditionType; value?: string }
type DisplayConditions = { include: ConditionRule[]; exclude: ConditionRule[] }ConditionType values and their specificity scores:
| Condition type | Score |
|---|---|
page_id |
100 |
page_slug |
90 |
homepage / not_found / coming_soon / maintenance
|
80 |
module |
50 |
path_prefix |
40 |
entire_site |
10 |
resolveThemeLayout(type, renderContext) in lib/layout/resolveThemeLayout.ts fetches all published layouts of the requested type, scores each one's include rules against the current render context, eliminates any layout that matches an exclude rule, and returns the highest-scoring layout. Ties are broken by priority then updatedAt. A layout with no include rules is skipped entirely (scoreConditions returns -1), not used as a fallback.
resolveThemeLayout selects neither publishedData nor history: every caller renders builderData, and both of the others hold whole Puck payloads on a query that runs at least three times per public page render.
page_id, page_slug, module and path_prefix mean nothing without a value (VALUE_REQUIRED in lib/layout/displayConditions.ts). A rule whose value is missing or blank matches nothing - isCompleteRule gates matchesRule before the switch.
This is load-bearing, not tidiness. Before it, a rule the owner added and never filled in compared undefined === undefined and matched: a blank page_id rule scored 100 - the top of the table - on every render context with no page id (a 404, a status screen), and a blank path_prefix matched every path on earth via startsWith(''). Either one silently replaced the site's layout with one that was never meant for it.
Following from that: PATCH /api/admin/layouts/[id] refuses status: 'published' unless the layout is left with at least one complete include rule, and the Layouts list badges a published layout with none as Not shown rather than pretending it is live.
path_prefix: /blog matches /blog and /blog/hello, and does not match /blogging-tips. A trailing slash on the rule is ignored.
defaultConditionsForLayout(type) gives a layout created from the picker the rules it had no choice about: a notFound layout starts on not_found, and a module layout type (Product, Post, Category, โฆ) starts on entire_site, which is the only rule it is offered - those are resolved by type alone. Headers, footers, page layouts and status pages start with no conditions on purpose: there, which layout wins is a decision, and pre-answering it is how a site's design changes without anyone asking for it.
matchesRule, scoreConditions, isCompleteRule, conditionTypesForLayout and defaultConditionsForLayout all live in lib/layout/displayConditions.ts - the same module the admin's Conditions panel reads, so the panel cannot offer a rule the renderer ignores. Unit tests: lib/layout/displayConditions.test.ts.
renderLayoutWithContent(layoutData, pageContent) in lib/puck/renderLayoutWithContent.tsx patches the Puck config at render time: it overrides the ContentSlot component's render function to return the real page React node, then calls <Render> with the patched config. This means layouts are ordinary Puck builder data; the ContentSlot is just a positioned placeholder that gets swapped at render time with no special data format needed.
| Export | Used in |
|---|---|
puckConfig / puckRscConfig
|
Page builder (editor / RSC render) |
headerPuckConfig / headerPuckRscConfig
|
Header layout editor / public header render |
footerPuckConfig / footerPuckRscConfig
|
Footer layout editor / public footer render |
fullPagePuckConfig / fullPagePuckRscConfig
|
404 and status page layout editors |
layoutPuckConfig / layoutPuckRscConfig
|
infoPage layout editor / public layout render |
RSC variants replace richtext fields with textarea (prevents React.lazy in RSC) and replace SiteLogoClient with SiteLogoRsc. The layout editor selects which config to use via a getConfig(type) switch in LayoutPuckEditor.tsx.
lib/puck/config.tsx is shared by the client editor and the RSC render, so it is deliberately hook-free and cannot reach the database or next/headers. A block that needs a site-wide value therefore has to be handed it. There are three established channels, in order of preference:
-
A CSS variable, when the value is expressible in CSS.
lib/design/tokens.tsemits--img-radiusand friends; blocks just consumevar(--img-radius, 6px). Costs nothing and needs no plumbing. -
Puck's
metadata, when it isn't.<Render metadata={...}>puts an object on every block'spuck.metadata, per render rather than per module, so there is no cross-request state to leak - it arrives through the same door aspuck.isEditing.lib/puck/renderMetadata.tsresolves it server-side (getPuckRenderMetadata(), backed by thecache()dgetSiteConfig, so the header/content/footer renders of one page share a query); the pure helper blocks actually call lives inlib/puck/imgLoading.ts, importing nothing, because anything reaching prisma from there would followconfig.tsxinto the browser bundle. - A prop threaded from the render root, for values that are per-block rather than per-site.
The lazy-load switch (below) is the current example of (2). Every RSC Render call site passes the metadata: app/(public)/layout.tsx (header, footer), lib/puck/renderInfoPage.tsx and renderLayoutWithContent.tsx (page content and its layout), app/not-found.tsx, app/cactus-status/{maintenance,coming-soon}/page.tsx, and app/layout-preview/[id]/page.tsx. A render that somehow arrives without metadata falls back to lazy, i.e. to how the blocks behaved before the setting existed.
SiteConfig.lazyLoadImages (Settings โ General โ Media, default true) sets loading="lazy" vs "eager" on every core content image: the Image block, Quote photos, Card covers, Panel images and logo strips.
The Image block included, deliberately. It carried an exemption when the setting first landed, on the grounds that an Image block can be the first thing on a page and lazy-loading the LCP element measurably delays it. That reasoning is still true, and the trade is real - with the switch on (the default) a hero Image block is lazy. It is exposed rather than decided in code because it is the site owner's call: the help text names the cost and points at the switch as the first thing to try if the top of a page feels slow. A blanket exemption also made the switch quietly untrue to its own label.
Per-panel override. The Image + Floating Chips block has an "Image loading" field (default "Site default") whose "Load immediately (above the fold)" option opts that one image out of the site-wide switch: it renders loading="eager" plus fetchPriority="high", the same treatment the Hero block's side image has always had. It exists because that block is the designed hero-panel, so its image is usually the largest thing above the fold and the page's LCP element - lazy-loading it is exactly the cost the paragraph above describes, but flipping the whole site to eager to fix one hero is the wrong trade. Other image blocks stay governed by the switch alone.
Module blocks render their own <img> tags in their own repos and do not consult this setting.
Shop product cards lazy-load unconditionally, and are worth calling out because for a long time they did neither: they carried no loading attribute at all, so a category page of 43 cards fetched 43 full-size photographs (natural widths commonly 1700-2000px, dropped into a 278px slot on desktop and 165px on a phone) before the first row had finished painting. All three card paths - ShopCardMedia (the carousel island), the plain single-image path in card-parts.tsx, and MinimalCard - now set loading="lazy" decoding="async". It is hardcoded rather than wired to the switch above because shop's card <Render> calls pass no metadata: imgLoading(puck) would have returned lazy unconditionally anyway, and pretending the switch reached these surfaces would be the misleading option. A shop owner who turns lazy-loading off still gets lazy product cards.
Starter templates are in-code data, not database rows. lib/layout/starter-templates.ts is the single source of truth: a Record<layoutType, StarterTemplate[]> of core templates, merged at read time with the module-declared ones from the generated lib/setup/module-starter-layouts.ts. It imports nothing server-only, because the admin's new-layout picker (app/cactus-admin/layouts/new/page.tsx) imports it straight into the browser and copies the chosen template's data into an ordinary POST /api/admin/layouts. The resulting Layout is fully editable, deletable and publishable from birth - there is no isStarter row, no read-only state, and no starter clutter in the Layouts list.
| Type | Count | IDs |
|---|---|---|
header |
14 |
starter-header, -logo-name, -logo-nav-login, -nav-centre, -logo-centre, -logo-right, -stacked, -login-toggle, -tall, -compact, -full-width, -minimal, -transparent, -blank
|
footer |
5 |
starter-footer, -logo-links, -three-col, -social, -blank
|
infoPage |
4 |
starter-full-width, starter-boxed, starter-sidebar-right, starter-sidebar-left (no blank - a page layout with no ContentSlot renders an empty page) |
notFound |
4 |
starter-404-hero, -minimal, -branded, -blank
|
statusPage |
4 |
starter-status-coming-soon, -maintenance, -minimal, -blank
|
lib/setup/starterLayouts.ts does its work in four deliberately separate jobs:
-
seedDefaultLayouts(db)- setup only, core templates only. Called from/api/setup/completeandreset-database. Each core template flaggedpublishByDefault(default header, footer, full-width page, 404 hero, coming-soon and maintenance) is seeded as one ordinary published Layout with id<template-id>-liveand the template'sdefaultConditions. Create-only upsert, so re-running never overwrites the owner's work. The-liveid suffix is load-bearing: that is where existing installs' live layouts already sit, and renaming it would seed them a second header. It does not touch module templates, and this is the fix for a real bug: setup completes with zero modules installed, so seeding the module set there stamped (for instance) Shop cart/checkout/product/confirmation layouts into every fresh site, whether or not it would ever have a Shop. -
seedModuleDefaultLayouts(db, name)- one module's install. A module'spublishByDefaulttemplates (for a page with no hardcoded fallback, i.e. Shop's) are seeded when that module's install deploy lands -markModulesDeploySucceeded()inlib/deploy/reconcile.ts. That is the earliest possible moment: a module's code only reaches the build with that deploy, so until then its templates do not exist to copy. Guarded once by theModule.layoutsSeededAtcolumn, not by the create-only upsert alone - a module update redeploys down the same path, and re-minting would resurrect a-livelayout the owner had since deleted. The second guard,isModuleInBuild(name), is the one that makes the first honest:markModulesDeploySucceeded()runs on whichever instance is serving when the webhook or status poll arrives, routinely the previous build, which has no copy of the module's code and therefore none of its templates. Seeding there wrote nothing and stamped anyway, turning "seed once" into never - the Shop on dwoffice.furniture was stamped 17 seconds before its own migrations first ran, soshopProduct/shopIndex/shopCheckout/shopConfirmationhad no Layout row at all and every product URL 404ed (those pages are Puck-only; there is no fallback to save you). The check asks the generatedmodulesInBuildlist about the code, because "found no templates" cannot distinguish a module with no starters from one whose code has not landed. Absent from the build = skipped unstamped, so the deploy carrying the code seeds it. -
seedPendingModuleLayouts(db)- recovery. Seeds any installed module still carrying a NULLlayoutsSeededAtwhose code is in this build, then stamps it. Called fromensureLayoutsCurrent()on a request path rather than from reconcile, which only runs when a module deploy finishes: a site already mis-stamped would otherwise stay broken until the owner happened to install or update another module. It places the module'sautoPlaceBlocksmarker blocks too, inside the same NULL guard and before the stamp - both halves of an install belong to whichever path actually gets to run, and this is the one running on a build that has the module's code. Leaving placement to reconcile alone meant a module whose reconcile landed on the previous build never got its blocks at all: recovery seeded the layouts, stamped the module and shut the door on a placement that had never happened. A module with a marker block and no layout types of its own - an abandoned-basket tracker, a tag - came out of that installed, active, configured and doing nothing, with no way back, since the stamped-with-no-rows second chance is keyed on declared layout types it does not have.abandoned-carts-for-shopon deskwell.co.uk landed in exactly that hole and had its header block placed by hand. NULL-only, so it can never re-mint a layout an owner deleted - a seeded module keeps its stamp whatever happens to the rows afterwards.prisma/core-reconcile/009_reseed_unseeded_module_layouts.sqlis what clears the bad stamps for it to find, and it proves rather than guesses: a stamp older than the module's earliestModuleMigration.appliedAtwas written before that module's code was ever in a build (the ledger rows are written by the build step, with the code present), so it cannot have seeded anything. It also heals006's backfill, which assumed the old setup-time seeder had covered every existing module - untrue for any module installed after setup. -
pruneUninstalledModuleLayouts(db)- clean-up. Deletes layouts whose module the site does not have. Run fromensureLayoutsCurrent()(once per core version, catching everything the old setup-time seeder left behind) and on module uninstall. "Does not have" is strict on purpose: noModulerow AND noModuleMigrationrows. Acode_onlyuninstall keeps both the module's tables and its migration ledger so a reinstall picks the data back up, so its layouts are kept too;code_and_dataclears both; a never-installed module has neither. A layout type that no longer maps to any module in the build is left alone - nothing to check it against, and a module whose checkout merely failed once must not cost the owner their layouts. The pure plannerplanOrphanLayoutTypesis unit-tested instarterLayouts.test.ts. -
ensureLayoutsCurrent()- request time. RunspruneLegacyStarterCopiesandpruneUninstalledModuleLayoutsonce per core version, gated onSiteConfig.starterTemplatesVersionvspackage.json(called from the public layout and the admin layouts list API; a module-level flag keeps it to oneSiteConfigquery per warm instance, and a failed run leaves the stamp unwritten so a later request retries), thenseedPendingModuleLayoutson every warm instance - after the prune, so a module's freshly seeded layouts are never binned by the same pass that created them, and outside the version gate, since recovery must not wait for the next core update. It never re-creates a layout a seeded module once had - an existing site already has its layouts, and seeding a second site-wide header into one would silently change its design. A NULL stamp is the one case where the seed provably never happened.
Which module layouts a site is offered is gated on installation, not on the build. Every build clones every module in modules.json, so lib/layout/module-layout-types.ts (generated) lists Shop even on a Shop-less site. The Layouts list and picker instead read getInstalledModuleLayoutGroups() (lib/layout/installed-layout-types.ts, Module.status IN ('active','update_available') - the same gate the admin sidebar applies to nav entries), resolved by the server shell app/cactus-admin/layouts/layout.tsx and passed to the client screens through components/admin/ModuleLayoutGroupsContext.tsx. isInstalledLayoutType() is the matching server gate on POST /api/admin/layouts and PATCH /api/admin/layouts/[id], so a shopProduct layout cannot be created or retyped into a site that has no Shop. isKnownLayoutType() stays build-time deliberately: an existing module layout must still open in its editor.
Retirement is split across build time and runtime, and the order is load-bearing.
prisma/core-reconcile/005_retire_starter_layouts.sql runs at build time and does two things in one file, in this order: deletes the isStarter rows, then drops the isStarter column and its index. It has to be that way round, and it has to be SQL. Had the row deletion stayed at runtime, the reconcile would have dropped the column during the build, the runtime where: { isStarter: true } would then have thrown against a column that no longer existed, ensureLayoutsCurrent's catch would have swallowed it, the version stamp would never have been written, and the rows would have survived forever. The whole file is idempotent: the DELETE is wrapped in a DO $$ block guarded on the column still existing, and the drops are IF EXISTS. A starter row that somehow does carry display conditions is left alone (it is rendering somebody's site) and simply becomes an ordinary layout once the flag column disappears. 004_perf_indexes.sql had to stop creating Layout_isStarter_idx at the same time - reconcile files re-run on every build, so that CREATE INDEX would have failed on the first build after the column was dropped.
The <id>-live / <id>-edited copies stay a runtime job, because deciding them needs a content comparison SQL cannot do. The old scheme's migration branch keyed off status === 'published' - which every starter row also was - so it minted a <id>-live copy of every starter on every core update, not just the handful a fresh install goes live with, and older versions minted <id>-edited copies too. A site that has taken a few updates is carrying dozens of published-but-condition-less layouts that render nowhere.
planStarterCleanup(rows, templates) (pure, unit-tested in starterLayouts.test.ts) deletes a row only when all three hold:
- Its id is
<known-template-id>-liveor-edited. Only the old seeder ever minted those ids; every layout an owner makes, Duplicate included, gets a cuid. - Its
displayConditions.includeis empty, so it renders nowhere and deleting it cannot change how the site looks. - Nobody has ever touched it - proven either by the row never having been saved (
updatedAtstill within five seconds ofcreatedAt, andpublishedAtnull: every edit and every publish movesupdatedAt) or by itsbuilderDatastill stable-stringifying equal to the canonical template.
Two proofs for (3) because either alone leaks. Content equality alone misses a copy stamped from an older vintage of the same template: block props get renamed as the builder grows (logoHeight โ cellHeight, the flat borderTop/borderColor pair โ a nested border object), so a copy the seeder stamped two years ago no longer matches today's catalogue and would sit in the Layouts list for ever despite nobody having opened it. On the Tester database that was 22 of the 75 dead rows. Timestamps alone would miss a row whose updatedAt was nudged by some unrelated write. A row that fails both was genuinely worked on, and survives - the owner's to delete by hand. None of these keys touch the dropped column, so the prune keeps working after the drop.
Deploy-window caveat. The reconcile drops the column during the Vercel build, while the previous deployment is still serving. Old code that bare-selects a Layout (GET /api/admin/layouts, the duplicate route) will 500 until the new deployment is promoted - a couple of minutes, admin-only. The public site is unaffected: resolveThemeLayout passes an explicit select that never named isStarter.
lib/setup/stylesInfoPage.ts seeds a Styles info page (slug style-guide) alongside the default layouts. It is installed by default as a draft (bodyFormat: 'builder', status: 'draft') by both app/api/setup/complete/route.ts and the reset-database soft-reset seed, via upsertStylesInfoPage. Like the default layouts it is upserted (only ever writes builderData, never publishedData), so it never overwrites a published copy and is safe to re-run.
The page is a single RichTextBlock of raw HTML inside a boxed Section. Because the public/RSC render pipes the HTML string straight to the DOM inside <main>, its h1โh6, a, button, img, input, select, textarea and label elements pick up the main โฆ rules from buildTokenStyles, so the page is a live reference of every option on Appearance โ Styles. A scoped <style> (targeting a .sg wrapper, later in source order so it wins on equal specificity) restores heading size/colour and link colour that the .puck-richtext rules in globals.css would otherwise override. Preview it at /page-preview/<id> while it stays a draft.
lib/layout/resolveLayout.ts is the original three-tier fallback (InfoPage.layoutId โ ModuleLayoutDefault โ SiteConfig.defaultLayoutId). It is kept for backwards compatibility with any module that calls it directly but is no longer used by the core public routes. New code should use resolveThemeLayout.
Everything a module needs to turn a designed layout into a PDF, in core rather than in whichever module got there first. It was written in shop (the invoice), copied wholesale into quote-for-shop (the quote), and the second copy was already a release behind the first on the cache-busting nonce - which is what a third copy would have been the moment anything else printed anything.
| File | Contents | Reach |
|---|---|---|
page-settings.tsx |
documentPageSettings / documentFooterPageSettings (the root config a document layout type declares), docPageSetup / docPageSetupFromLayout, the @page emitter, DocumentFooterRegion and PDF_FOOTER_REGION_ID. |
Client-safe - reached from the Puck editor bundle. |
pdf.ts |
renderDocumentPdf({ path, pageSetup, footerCss, label }), printPath() (the nonce), documentPdfFilename(), DocumentPdfUnavailableError, and the running-footer capture. Dynamically imports puppeteer-core and @sparticuz/chromium. |
Server. |
footer.tsx |
renderDocumentRunningFooter(ctx, { fallbackLayoutTypes, moduleName }). |
Server, and heavy - it reads layouts and module-rsc-components. |
context.ts |
injectDocumentContext(data, ctx, partTypes). |
Isomorphic, pure. |
The capture deliberately lives beside the printer rather than beside the footer renderer: it only ever runs against a puppeteer Page, and pairing it with the renderer would drag the database and every module's RSC block map into every PDF route.
documentFooter is a core layout type, and the first one core owns that is built the way a module's is - its picker is the blocks declared for that type plus the shared core set, and its root fields come from the hand-written lib/puck/core-layout-roots.ts rather than the generated module-layout-roots.ts. Four places had to learn it: CORE_LAYOUT_TYPES in lib/layout/layout-type-tabs.ts, CORE_STARTER_TEMPLATES, and the getConfig switches in LayoutPuckEditor.tsx and app/layout-preview/[id]/page.tsx, both of which now ask isCoreModuleStyleLayoutType() alongside moduleLayoutTypeToGroup. Pinned by lib/documents/document-footer-layout.test.ts, because getting any one of them wrong leaves a layout type that saves and publishes perfectly well and simply opens in the wrong editor.
renderDocumentRunningFooter resolves documentFooter and nothing else. It still accepts fallbackLayoutTypes - shop used to pass ['shopDocumentFooter'], the type it shipped before core had one - but shop 0.1.354 retired that type instead: migration 030_document_footer_layout.sql moves any layout saved under it onto documentFooter, which keeps the owner's design editable rather than stranding it under a type no editor opens. The option stays in core's signature because modules are pinned separately: an install still on an older shop or purchase-orders passes it, and a core that ignored it would drop that site's footer on the next core update. Which blocks get _ctx is derived from moduleRscComponentsByLayoutType[layoutType], so a module contributes footer blocks by naming documentFooter in the block's own layoutTypes and nothing in core changes.
Blocks in documentFooter carry no vertical spacing by default. withoutVerticalSpaceComponents() in lib/puck/config.core.tsx maps every block in that one layout type's config - core and module alike - so spaceAbove, spaceBelow, paddingY and rich text's paraSpace start at nothing, and it is applied by BOTH getModuleLayoutPuckConfig() (editor) and getModuleLayoutPuckRscConfig() (published render, outside withSafeRichText so it wraps the RSC render actually used). It fills the blank only: a value an owner has saved wins, and the Space block is untouched. The render wrapper is the load-bearing half - Puck's RSC Render hands a block exactly the props that were stored and merges defaultProps only at the moment a block is dropped in, so a footer published before those fields existed carries none of them and would otherwise fall to the block's own web-page 1.5rem. The reason is the surface: the footer is drawn into a page's bottom margin, where a Divider's 1.5rem above and below is most of the strip. Pinned by lib/documents/document-footer-spacing.test.tsx.
A module contributes STARTERS to documentFooter too, through a top-level layoutStarters: [{ layoutType, import, export }] in its manifest - a separate key from layoutTypes.types on purpose, because an older core ignores a key it has never heard of where a types entry would be registered as a layout type of the module's own and put the retired tab straight back. The generator collects them into moduleLayoutStarterContributions in lib/setup/module-starter-layouts.ts, keyed by layout type and holding a list, and getStarterTemplates() appends them to whatever the type already offers. They are deliberately absent from moduleStarterTemplates(moduleName), which is what seeds a module's own layouts on install: nothing publishes a document footer by default, because seeding one would silently redesign the footer on every document a live site prints.
Admin โ Appearance โ Styles (/cacti/appearance/styles) is a seven-tab editor for all visual design tokens, stored as SiteConfig.designTokens (nullable JSON, version: 2). The shape is defined in lib/design/tokens.ts.
{
version: 2,
designSystem: {
colours: GlobalColour[] // { id, name, light, dark }
fonts: GlobalFont[] // { id, name, family, weight, ... }
},
themeStyle: {
background: { colour? }
body: Typo & { colour? }
display?: Typo & { colour? } // hero/largest heading, above h1 - added post-launch, optional
caption?: Typo & { colour? } // small label/footnote text, usable anywhere - added post-launch, optional
links: { colour?, hoverColour? }
headings: { h1..h6: Typo & { colour? } }
buttons: { typo: Typo, textColour?, bgColour?, borderColour?,
borderWidth?, borderRadius?, padding?,
hover: { textColour?, bgColour? } }
images: { borderRadius?, borderColour?, borderWidth? }
formFields: { typo: Typo, labelTypo: Typo, textColour?, bgColour?,
borderColour?, borderRadius?, labelColour? }
spacing?: { blockPadding?, tabletBreakpoint?, mobileBreakpoint? } // block gutter (--block-padding) + Grid/Split responsive breakpoints, defaults '1024px'/'640px'
}
}| Tab | Sections |
|---|---|
| Colours | Colour Presets, Global colours (up to 12, with light/dark variants), Page background, Links (colour and hover colour) |
| Fonts & Typography | Global fonts (named font definitions), Body text, Caption / small text (for labels, badges, footnotes - used by the Caption Puck block and available anywhere, not just form-field labels) |
| Headings | Display (hero/largest, above H1 - used by the Heading block's "Display" level, for homepage heroes and campaign banners), H1-H6 (collapsible, each with full typographic controls and colour) |
| Buttons | Typography, text/background/border colours, border width/radius/padding, hover state |
| Images | Border radius, border width, border colour |
| Form Fields | Label typography and colour, field typography and colours (text, background, border, radius) |
| Spacing & Breakpoints | Default block padding (left/right) - the site-wide gutter (--block-padding, default 1.5rem) applied to content blocks so they don't run to the page edges; Tablet/Mobile breakpoint (default 1024px/640px) - screen widths where Grid/Split blocks drop to fewer columns, see Responsive breakpoints above |
Colour fields on every tab show palette swatches from Global colours; selecting a swatch stores the raw hex (not a var reference).
The Colours tab opens with a horizontally-scrollable row of ten named presets (Prickly, Bloom, Desert, Dusk, Spine, Mirage, Ember, Mesa, Monsoon, Sagebrush). Applying a preset updates the primary palette colour (light and dark variants) and themeStyle.links.colour / themeStyle.links.hoverColour. All other tokens are unchanged. If the user has unsaved colour edits, a confirmation prompt fires before applying. The active preset is detected by matching the current primary colour, link colour, and link hover colour against the preset definitions; custom values show a "Customised" label instead.
Shared across admin settings pages via the useUnsavedChanges hook and UnsavedChangesModal component (both in components/admin/). The hook owns a dirtyRef the page flips true on edit and resets on save. While dirty, leaving the page is guarded two ways: a beforeunload handler covers hard navigations (reload, tab close), and a capture-phase document click listener intercepts in-app admin link clicks, setting a pendingHref that renders the modal. The modal offers Save & leave, Discard & leave, and Cancel; "Save & leave" only navigates if the save succeeds, so a failed save keeps the admin on the page with the error visible. Pages with no single save action (e.g. Account) render the modal without an onSave, so only Discard & leave / Cancel show.
The guard is wired into Styles (dirty on any token edit; also resets on preset apply), Settings (/config - dirty when the form diverges from the last-saved fingerprint, which excludes mediaProvider since that saves immediately on its own), and Account (dirty when the display name changes or any email/password field has input).
Weight, transform, style, and text-decoration are dropdowns (not free text). If saved data holds a value outside the option list, it is surfaced as a "<value> (custom)" option so it is preserved rather than silently dropped. Size, line-height, and letter-spacing remain free-text (arbitrary CSS units).
The font-family picker (FontPickerField) lists your named global fonts first under a "Your fonts" heading (picking one stores its family value), then the built-in POPULAR_FONTS list; free text is still accepted for any CSS font-family value. The global-fonts editor's own family field does not list the global fonts (it defines them).
PATCH /api/admin/appearance requires appearance.manage and shape-guards the payload: designTokens must be null (clear) or an object with version === 2; anything else is rejected with 400 rather than persisted.
After a successful save the Styles page calls router.refresh(), which re-renders the server admin layout so its injected theme (buildAdminThemeStyles - primary colour + font) updates immediately without a manual reload.
lib/design/tokens.ts exports buildTokenStyles(tokens) and buildFontHref(tokens). Both accept unknown and handle null/v1 data gracefully.
buildTokenStyles emits:
:root, [data-theme="light"] {
--color-1..N: <light hex>; /* from designSystem.colours */
--color-primary: <primary light hex>; /* the `primary` colour (or first) */
--color-primary-hover/-active/-dark/-subtle/-border; /* derived shades (darken) */
--color-on-primary: #111111 | #ffffff; /* legible foreground (WCAG luminance) */
--sp-1..9: 4,8,12,16,24,32,48,64,96px; /* fixed */
--radius-sm: 2px; --radius-md: 6px; --radius-lg: 9999px; /* fixed */
--shadow-subtle: ...; --shadow-elevated: ...; /* fixed */
--font-body: ...; --font-heading: ...; /* body.family, else the primary global font */
--color-link: ...; --color-link-hover: ...; /* from themeStyle.links */
--h1-family/-weight/-size/-line-height/-letter-spacing/-transform/-style/-color .. --h6-*; /* full per-heading typography */
--display-family/-weight/-size/-line-height/-letter-spacing/-transform/-style/-color; /* from themeStyle.display */
--caption-family/-weight/-size/-line-height/-letter-spacing/-transform/-style/-color; /* from themeStyle.caption */
--btn-family/-weight/-size/-line-height/-letter-spacing/-transform/-style; /* button typography */
/* + btn colour/border/radius/padding/hover, img, field vars */
--block-padding: 1.5rem; /* from themeStyle.spacing.blockPadding - default Puck block gutter */
}
[data-theme="dark"] {
--color-1..N: <dark hex>;
--color-primary + shades: <primary dark hex>; /* dark mode lightens for hover/active */
}
@media (prefers-color-scheme: dark) { /* same dark colours + primary */ }
/* scoped defaults: main { body typo } main h1,a,button,img,input,label { ... } */
/* main .cactus-display { ... } main .cactus-caption { ... } - class-based, since neither has one native tag */
@media (max-width: 640px) { .puck-grid,.puck-split { grid-template-columns: 1fr !important } }
@media (min-width: 640px) and (max-width: 1024px) { .puck-grid[data-cols="3"],.puck-grid[data-cols="4"] { grid-template-columns: repeat(2,1fr) !important } }
/* tabletBreakpoint/mobileBreakpoint (default 1024px/640px) - not a var(), since a @media
width can't read a CSS custom property; always emitted regardless of whether spacing
is set, unlike the other rules above */display/caption are optional in the type (unlike the always-present headings/buttons/etc): they were added after initial launch, so a site's already-stored designTokens row - or the fresh-install default before a first Styles save - may not have these keys yet. Every read goes through ts?.display/ts?.caption with a ?? {} fallback, both in buildTokenStyles/buildFontHref and in the Styles page's own React state, so an old row with neither key present renders exactly as before (the Heading block's "Display" level and the Caption block just fall back to their own built-in defaults until a site owner sets real values).
Why the primary mapping matters: buttons, links, rich text accents and every Puck component consume the semantic --color-primary family (defined for the admin in globals.css). Mapping the primary design colour onto these variables (rather than only the indexed --color-N) is what makes a colour or preset change actually recolour the public site and the Puck editor canvas. The hover/active/subtle/border shades are derived from the single primary hex - darkened in light mode, lightened in dark mode - and --color-on-primary is chosen by WCAG relative luminance.
Why body typography targets main: the body font/size/colour are emitted on main itself (not main p) so they cascade into rich-text content. .puck-richtext p is a class selector that would out-specificity a plain main p rule and ignore the chosen body font; setting the font on the main ancestor lets it inherit through instead.
The primary global font is the site default. The primary global font (or the first defined font) provides the default family/weight for body text: body.family || primaryFont.family (same for weight). So changing the primary font actually restyles the site, and leaving the body font-family box empty inherits the primary font rather than the built-in Cactus face (which isn't even loaded on the public frontend, so it falls back to system-ui). This font is emitted as --font-body/--font-heading, on the scoped main{} rule (for rich-text cascade), and as --font-sans - the base UI typeface - so text outside <main> (header, footer) and native form controls (which don't inherit font-family) also pick it up. Headings inherit through the main cascade unless they set their own family. The admin chrome also adopts this font (see buildAdminThemeStyles below).
Page background: themeStyle.background.colour is emitted only as the --color-page-bg variable (no scoped rule). globals.css applies it via body { background: var(--color-page-bg, var(--color-bg)); }, so it covers the whole page (not just main). In the admin the variable is never emitted (buildAdminThemeStyles is a primary-only subset), so admin falls back to --color-bg and is unaffected.
buildFontHref inspects all font-family values across the token tree, skips system stacks (system-ui, Arial, Georgia, Helvetica, Times, sans-serif, serif, monospace, -apple-system), and returns a Google Fonts URL or null.
-
app/(public)/layout.tsx- emits<link rel="stylesheet">for Google Fonts and<style>with token CSS for every public page. -
app/layout-preview/[id]/page.tsx- same injection, so the standalone layout preview matches the live site. -
app/cactus-admin/layouts/[id]/LayoutPuckEditor.tsx- injects token styles intodocument.headviauseEffectso the inline Puck canvas reflects the current theme (best-effort; Puck renders inline, not in an iframe). -
app/cactus-admin/layout.tsx- injectsbuildAdminThemeStyles(designTokens)so the admin chrome (sidebar active state, buttons, badges, focus accents) white-labels to the site's primary colour, and overrides--font-sanswith the site's primary font so the admin UI typeface matches the site (the mono/code font--font-monois left alone). It also emitsbuildFontHref(designTokens)as a<link>so that font actually loads in admin. This is still a narrow subset - only the--color-primaryfamily plus--font-sans- so the admin keeps its own spacing, radii and shadows. Injecting the fullbuildTokenStyleshere would be wrong: its fixed--radius-*/--sp-*/--shadow-*block and scopedmain โฆrules would clash with the admin design system. The login and setup screens are outside this layout and stay on the base Cactus palette.
lib/puck/SiteColourField.tsx fetches colours from /api/admin/appearance (path: designTokens.designSystem.colours) and renders named swatches. Selecting a swatch stores var(--color-N) as the field value, so colour changes propagate to all blocks automatically without re-saving pages.
The scoped main โฆ rules only reach content with no inline styles (rich text, raw HTML). Puck blocks render with inline styles, which out-specificity any stylesheet rule, so a few blocks read the tokens as CSS variables inline, each with a fallback to the original built-in look (untouched sites are byte-identical):
-
Button block (
ButtonLink) renders<a class="cactus-btn">reading--btn-family/-weight/-size/-line-height/-letter-spacing/-transform/-style,--btn-radius,--btn-paddingfor shape/type (all variants), and--btn-bg/--btn-text-color/--btn-border/--btn-border-widthfor the primary (default) variant's colours. Hover uses the token hover colours viamain .cactus-btn:hover{โฆ!important}(the!importantis required to beat the inline base state). Secondary/outline keep their own colours but still inherit shape/type and the global border. The fourth Custom style is the deliberate exception to all of this: its colours and border come from the block's own props, not the tokens, so a one-off button can break from the site's button styling without dragging every other button with it. It still inherits shape/type (radius, padding, typography) from the tokens, and each colour left blank falls back to the primary token, so a freshly-switched Custom button starts out identical to the Primary it came from. Its hover is emitted as a block-scopeda[data-btn-link="<id>"]:hoverrule instead: the global hover rules are keyed on[data-variant="primary"|"secondary"|"outline"], whichcustomintentionally matches none of. -
Heading block reads
--{level}-family/-size/-weight/-line-height/-letter-spacing/-transform/-styleand, when no colour is set (or the legacy default "dark"),--{level}-color. Any explicit colour - a palette swatch, a custom/typed colour, or the legacy muted/brand presets - still wins. -
Image block (
ImageBlock) reads--img-radius,--img-border-width,--img-border-color. -
Contact-form module (
ContactFormClient) styles its public form via a.cactus-contact-form-scoped<style>reading--field-*(fields) and--field-label-*(labels) with neutral fallbacks, and its submit button uses the same.cactus-btntreatment as the Button block. It deliberately does not use the admin.field/.btnclasses, which would out-specificity the site theme and lock the form to the admin (green primary) look.
So buildTokenStyles emits full typography variable sets for headings (--h1-โฆ through --h6-โฆ), buttons (--btn-โฆ), form fields (--field-โฆ) and field labels (--field-label-โฆ), not just the colour/size subset. Composite blocks (Hero, CTABanner, Card CTAs) keep their bespoke styling and deliberately do not consume the button/heading tokens, so their contextual designs (e.g. a CTA on a brand-colour background) are preserved.
Cactus supports three dark-mode states: Auto (follows the OS), Light, and Dark. The preference is stored in localStorage as cactus-theme.
To prevent flash-of-wrong-theme on load, app/layout.tsx includes an inline <script> that runs before paint: it reads cactus-theme and always sets data-theme="dark" or data-theme="light" on <html> before the first paint. In auto mode it checks window.matchMedia('(prefers-color-scheme: dark)') to decide which to apply. A @media (prefers-color-scheme: dark) block in globals.css acts as a CSS-only fallback for SSR.
The ThemeToggle component (components/ThemeToggle.tsx) is a client component styled as a pill-shaped icon segmented control: three icon-only buttons in the order Light (sun) / Auto (split sun-moon disc) / Dark (moon), with a sliding circular knob highlighting the active mode and a custom CSS tooltip on hover/focus. Each button calls applyTheme(mode). The icons are inline SVG (no icon library) and inherit colour via currentColor. All styling lives in the .theme-toggle* block in globals.css and is token-based, so the control inverts correctly between light and dark; the knob slide honours prefers-reduced-motion. A compact variant (smaller buttons) is mounted in the admin sidebar above Sign out. When the sidebar is collapsed the component takes a collapsed prop and renders instead as a single .theme-toggle-cycle button showing only the active mode's icon; clicking it cycles Light โ Auto โ Dark โ Light (the NEXT_MODE map), so all three modes stay reachable without expanding the sidebar. The three SVG icons are shared between both variants. Accessibility: the segmented control uses role="group" with aria-label="Colour scheme" on the track, aria-pressed on each button, and a text aria-label per button (the visual tooltip is aria-hidden); the collapsed button carries an aria-label naming the current mode and the mode it switches to.
In the page/layout editors the Theme Toggle block also carries an appearance set - Icon size (px), Icon colour, Hover colour, Style (Default round icon button / Bordered pill / Filled / Plain (no box)), Background colour, Border colour and Corner radius (px) - deliberately the same field keys, labels and order as the Members: Sign In block and the shop's basket block, so a header row holding two or three of them can be dressed to match rather than one of them always drawing a grey pill next to plain icons. The existing style select is labelled Control type to keep it apart from the box Style. The appearance fields only show for the single-icon control types (expand, dropdown, cycle) - the segmented pill and the sun/moon switch are multi-part controls with their own geometry - and the box colours and radius hide themselves for the Default and Plain styles, which have no box to paint. Default is the shipped value and reproduces the round icon button exactly, so an existing toggle looks the same after an update until someone picks another style. The settings are applied as --tt-* CSS custom properties that the .theme-toggle-cycle rules read, each falling back to its historic value, so the admin sidebar's own toggle is untouched. Icon colour on its own still pins the hover to the same colour - a block that has picked an icon colour is opting out of the hover, matching the Members: Sign In and basket blocks - so Hover colour exists to break that tie: set it and it wins, leave it blank and nothing changes for any toggle already out there. Blank with no icon colour falls back to your site's main colour, the same hover the Icon Link and sign-in icons use. The block also carries Who can see this, the same field Icon Link has, with one difference: it ships set to Admins only, and a toggle saved before the field existed is treated the same way. A colour-scheme control is a thing most owners want to live with for a while before showing it to customers, so it stays yours until you switch it to Everyone. The gate is applied on the server, so a visitor's page never contains the toggle at all rather than hiding it with styling. It shows on the editor canvas whatever it is set to, or there would be no way to select it and change your mind.
The admin UI uses the Cactus Design System. Primitive palette tokens (--cactus-*, --spine-*, --sand-*) are defined in globals.css and mapped to semantic aliases (--color-bg, --color-text, --color-primary, --color-destructive, --color-border, etc.) in [data-theme="light"] and [data-theme="dark"] blocks. The UI typeface is Instrument Sans; developer content (slugs, paths, keys, code) uses JetBrains Mono. Admin-specific variables use --admin-* prefixes. All reusable UI classes (.card, .btn-*, alerts, badges, tables) reference these variables so they automatically adapt to both themes. Hardcoded hex values must not appear in component inline styles - always use CSS variable references (var(--color-text-muted), var(--color-destructive), etc.).
Cactus ships a built-in consent system. When the consent banner is enabled in the GDPR & Legal config tab, the following contract applies across the whole platform.
An inline <script> in app/layout.tsx (runs before any body content) reads the cactus-consent cookie and populates window.__cactusConsent as a flat Record<string, boolean>. Non-necessary categories default to false until the visitor makes a choice (deny-by-default). The cactus-consent-id cookie holds the visitor's anonymous UUID identity, which is linked to a userId when the visitor is authenticated.
import { hasConsent, onConsentChange, loadIfConsented } from '@/lib/consent/gate'
hasConsent('analytics') // true | false, reads window.__cactusConsent
onConsentChange(cb) // subscribe to future decisions, returns unsubscribe fn
loadIfConsented('analytics', fn) // run fn immediately if consented, else defer until consentedEvery decision is also announced as a plain CustomEvent on window, with the full decision map as its detail:
window.addEventListener('cactus:consent-change', (e) => {
const granted = (e as CustomEvent<Record<string, boolean>>).detail['live-chat'] === true
})Module code should use the event, not the listener Set. onConsentChange keeps its subscribers in a module-level Set inside lib/consent/gate.ts; a module bundled separately cannot assume it shares that instance. The event crosses bundles, needs no import, and pairs cleanly with useSyncExternalStore for a React component that has to appear and disappear with the decision.
window.cactusConsent.open() // programmatically re-open the consent bannerThe CookieSettingsLink Puck block calls this for you. Drop it into your footer layout so visitors always have a way to change their mind. open() reloads the visitor's stored decision before showing the manage view, so reviewing a choice never quietly offers to reset it.
components/consent/ConsentPreferencesPanel.tsx is a second front end onto the same decision: the category switches rendered inline, above the content of whichever page SiteConfig.privacyPolicyPageId points at. renderInfoPageContent (lib/puck/renderInfoPage.tsx) decides whether to draw it - the banner must be enabled, the page must be the linked privacy policy, and consentBannerConfig.showPrivacyPagePanel must not be false (absent counts as on, so sites configured before this existed get it). Where the page has a layout, the panel goes inside the content slot, not above it, or it would land above the site header.
Both surfaces share lib/consent/client.ts, which owns the cookie names, the payload shape, and the audit POST. Anything that writes a consent decision should go through saveConsentDecision rather than touching the cookie itself - two copies of that logic is exactly how the banner and the panel would drift apart.
Every decision is appended to ConsentRecord (append-only audit log). Current consent = the latest row for a given consentId. The decision column is a JSON snapshot of per-category booleans. The action column is one of accept_all | reject_all | custom | withdraw. Stored metadata is minimal: truncated IP (IPv4 last octet zeroed; IPv6 /48 prefix only) and a SHA-256 hash of the user-agent string. Raw IP and raw UA are never persisted.
Any module that sets non-necessary cookies must declare those categories in its manifest cookieCategories array, using the same key shape the banner enforces: lowercase, starting with a letter, letters/numbers/hyphens/underscores only (live-chat). It must not load tracking scripts unconditionally - use loadIfConsented(category, fn) to gate them. The admin consent banner editor surfaces the module's declared categories as one-click suggestions; a category worded loosely in a manifest is folded into a valid key there, with the module's wording kept as the label, so an older module still adds cleanly. A manifest may also declare the long form - { "key": "analytics", "label": "Analytics", "description": "โฆ" } - so the accepted suggestion arrives with its description already written rather than as an unexplained switch on the visitor's banner. Where two modules suggest the same key, whichever brought a description wins. See Authoring a module for details.
A module whose visible UI is itself the processing - a chat launcher, a video embed, a map - should not merely gate the script behind the category, but do none of the processing until the category is granted. A visitor who has not answered the banner has no decision recorded, so the same check covers "has not chosen yet" and "chose no".
Rendering nothing at all is the safe default, but for a feature the visitor might go looking for it is worth showing an inert placeholder that says what is missing and offers window.cactusConsent.open() underneath - a launcher that simply vanishes reads as "this site does not have that", and someone who declined has no way back. Whether the visitor has answered the banner at all (rather than answered and declined) is not in window.__cactusConsent, which flattens both to false; the presence of the cactus-consent cookie is the signal for that, and is all a module should read of it. The live-chat module is the reference implementation: see Live Chat.
Cactus can only gate scripts it injects itself. Third-party snippets pasted directly into page HTML (e.g. raw GA <script> tags) cannot be suppressed by this system. Document this to site operators.
lib/analytics/conversion.ts is how one module tells another that something worth measuring happened - an order placed, a lead left, a quote asked for - without either module knowing the other exists. The shop must not depend on a measurement module being installed, and a measurement module must not depend on the shop. Both talk to core.
import { announceConversion } from '@/lib/analytics/conversion'
announceConversion({
type: 'purchase', // 'purchase' | 'lead' | 'quote' | 'signup' | your own string
transactionId: order.number, // supply wherever one exists - see dedup below
value: 249.5, // major units (pounds, not pence)
currency: 'GBP', // ISO 4217, required whenever value is set
tax: 41.58,
shipping: 0,
coupon: 'SPRING10',
items: [{ id: 'SKU-1', name: 'Desk', quantity: 1, price: 249.5, variant: 'Oak, 1600mm' }],
})The field names are receipt-shaped rather than shaped after any one advertising platform. Translating into a platform's payload is the listening module's job, which is what lets a second one (Meta, Bing, plain server logging) listen on the same event with no change to core.
import { onConversion } from '@/lib/analytics/conversion'
const unsubscribe = onConversion((c) => { if (c.type === 'purchase') /* โฆ */ })onConversion replays anything announced before this listener existed, then subscribes. That is not a nicety: a tag module loads its script asynchronously and will usually subscribe after the confirmation page has already announced the sale. The buffer lives on window.__cactusConversions rather than in module scope, because core, the announcing module and the listening module are three separate bundles and a module-scope array would be three arrays.
A conversion carrying a transactionId is announced once, in this visit and in every later one - the key is stored in localStorage (cactus-conversions-seen, most recent 40). A confirmation page that polls for a payment to settle, or that a shopper refreshes or bookmarks and returns to next week, therefore cannot count one order twice, and no listener has to remember to check. A conversion with no transactionId is never suppressed: without one there is nothing to tell two genuine events apart.
If two copies of a listening block can end up on one page, the listener must claim its subscription globally (a window flag) rather than per component instance. Core dedupes announcements, not subscribers - two subscribers each receive the replay.
Wiki: Home ยท Getting started ยท Running locally ยท Architecture overview ยท Members ยท Authoring a module ยท Authoring a theme ยท Self-hosting and operations
Sidebar favourites follow the person. User.sidebarFavourites (jsonb array of nav item ids, in pinned order; null = never saved) is read by the admin layout and handed to AdminNav. A non-null list wins and is mirrored into the browser's localStorage; null makes the sidebar upload the favourites that browser already held, order intact, so nothing is lost on the update that introduces this. Each star or drag saves through PUT /api/admin/nav-favourites (own row, any signed-in admin). Core-reconcile 046.