Recipe: managed-pages
Tenant-editable, server-rendered public pages with per-tenant branding and
opt-in, tier-gated custom CSS — the framework generalization of the
legal-pages/wrapLayout render pattern, hardened for untrusted tenant input.
What the recipe demonstrates:
- Compose
managed-pages(+ itsconfigdependency and themanaged-pages-csscompanion toggle) inrunProdApp({ features: [...] }) - Required wirings:
anonymousAccess+extraContextconfig plumbing - Single-tenant
resolveApexTenant(multi-tenant resolves from the Host) - The render boundary: published-only, Markdown raw-HTML escaped, per-tenant
branding applied as scoped
:rootvars, custom CSS allowlist-sanitized and contained in a<style data-tenant-css>block - 6 integration tests proving end-to-end behavior
Layout
Section titled “Layout”samples/recipes/managed-pages/├── package.json # workspace deps├── README.md # this file└── src/ ├── feature.ts # the composed features re-exported for tests └── __tests__/ └── feature.integration.test.ts # 6 tests: render + branding + CSSRun tests
Section titled “Run tests”# From the repo root:bun kumiko test all samples/recipes/managed-pages/When the tests are green:
- A published page is reachable via
stack.app.request("/p/about"); a draft is404 - Per-tenant content is cache-isolated (
Vary: Host) - Raw HTML in a Markdown body is escaped (no
<script>execution) - Branding writes (
config:write:set) roundtrip into the rendered HTML - Attack CSS (
position:fixed,@import,url()) is neutralized at render
Integration into a real app
Section titled “Integration into a real app”1. Compose the features
Section titled “1. Compose the features”import { runProdApp } from "@cosmicdrift/kumiko-dev-server";import { createManagedPagesFeature, createManagedPagesCssFeature,} from "@cosmicdrift/kumiko-bundled-features/managed-pages";
await runProdApp({ features: [ createManagedPagesFeature({ // Host → tenantId. Single-tenant returns a constant; multi-tenant maps // the subdomain / custom domain. NULL → 404 (no tenant for this host). resolveApexTenant: (host) => resolveTenantForHost(host), // Opt-in custom CSS (default false, fail-closed). The render-time // sanitizer is the safety boundary; gate per tier with the companion. allowCustomCss: true, }), createManagedPagesCssFeature(), /* ... your other features */ ], // routes run anonymous; multi-tenant honors the per-page X-Tenant header anonymousAccess: { tenantExists: async (id) => /* validate */ true },});config is auto-mounted by runProdApp. In setupTestStack it is not, so
the recipe lists createConfigFeature() explicitly (see src/feature.ts).
anonymousAccess: a fixed
defaultTenantIdlocks single-tenant and rejects the per-pageX-Tenantwith400 tenant_mismatchunless it equals the default. Multi-tenant apps wiretenantExists(or atenantResolver) and let the route’sX-Tenantwin.
2. Create the table
Section titled “2. Create the table”bun kumiko migrate generate # detects the `page` entity → SQL migrationbun kumiko migrate apply3. Authoring + branding
Section titled “3. Authoring + branding”- Pages: TenantAdmin/SystemAdmin author via the
entityList/entityEditscreens (managed-pages:screen:page-list); wire nav/workspace onto them. - Branding: the
configEditscreen (managed-pages:screen:branding-settings) orconfig:write:setagainst theBRANDING_QN.*keys (managed-pages:config:branding-{title,accent-color,...,custom-css}).
4. Per-tenant CSS gating
Section titled “4. Per-tenant CSS gating”createManagedPagesCssFeature() is r.toggleable({ default: false }). To gate
custom CSS per tier, wire feature-toggles/tier-engine so the
managed-pages-css toggle is on only for entitled tenants. Without a toggle
runtime, ctx.hasFeature fails open — the capability stays on. The fail-closed
anchor is allowCustomCss (app-level opt-in); the toggle is the commercial gate.
Custom wrapLayout (your own marketing chrome)
Section titled “Custom wrapLayout (your own marketing chrome)”A custom wrapLayout receives branding RAW and untrusted: title/
description are length-capped at write but not HTML-escaped, and
customCss is unsanitized. Emit every tenant value through the exported
boundary helpers — never interpolate branding.title yourself (stored XSS):
import { brandingHeaderHtml, brandingStyleBlock, tenantStyleBlock, TENANT_CONTENT_ATTR,} from "@cosmicdrift/kumiko-bundled-features/managed-pages";
const wrapLayout = (o) => `<!doctype html><html lang="${o.lang}"><head> <title>${o.title}</title> ${/* your layout CSS */ ""} ${brandingStyleBlock(o.branding)} <!-- escaped :root theme vars --> ${tenantStyleBlock(o.branding.customCss)} <!-- scoped + sanitized + contained --></head><body> ${brandingHeaderHtml(o.branding)} <!-- escaped logo + title header --> <main ${TENANT_CONTENT_ATTR}>${o.bodyHtml}</main></body></html>`;brandingHeaderHtml/brandingStyleBlock escape + re-validate (hex/https) the
branding tokens; tenantStyleBlock bakes in the scope selector so callers can’t
mis-scope and lose containment. The default skeleton (wrapInLayout) uses the
exact same helpers.
Security posture
Section titled “Security posture”| Threat | Mitigation |
|---|---|
<script>/raw HTML in a page body | renderSafeMarkdown escapes raw HTML (Markdown-only) |
| Cross-tenant content on a shared CDN | Vary: Host + per-tenant resolveApexTenant |
| Draft leak to anonymous visitors | published-only; drafts → 404 |
@import/url()/expression()/</style> in custom CSS | allowlist-by-construction sanitizer (rebuilt from validated tokens) |
| Scope-escape / overlay onto host chrome | scope-prefix + position/isolation/overflow containment |
| Untrusted tenant abusing CSS | tier-gate (managed-pages-css) + allowCustomCss opt-in |
Custom-CSS sanitization for untrusted tenants is best-effort defense-in-depth: a tenant can still restyle its own page area within the clip. Keep it tier-gated.
Cross-refs
Section titled “Cross-refs”- packages/bundled-features/src/managed-pages — the feature
- packages/bundled-features/src/page-render/css-sanitize.ts — the allowlist sanitizer
- samples/recipes/legal-pages — the simpler, static sibling
Source code
Section titled “Source code”The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):
import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";import { createManagedPagesCssFeature, createManagedPagesFeature,} from "@cosmicdrift/kumiko-bundled-features/managed-pages";import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
// managed-pages declares `r.requires("config")` for its branding keys — the// config feature must be in the stack (runProdApp auto-mounts it; setupTestStack// does not, so the recipe lists it explicitly).export const configFeature = createConfigFeature();
// Single-tenant apex: every request maps to the one system tenant. A multi-// tenant app resolves the tenant from the request Host (subdomain / custom// domain) and returns the matching tenantId instead — see README.export const managedPagesFeature = createManagedPagesFeature({ resolveApexTenant: () => SYSTEM_TENANT_ID, allowCustomCss: true,});
// Companion per-tenant toggle for the CSS-inject capability. Compose it and wire// feature-toggles to gate custom CSS per tier; without a toggle runtime,// `ctx.hasFeature` fails open and CSS stays on (the render-time sanitizer is the// safety boundary regardless).export const managedPagesCssFeature = createManagedPagesCssFeature();📄 On GitHub: samples/recipes/managed-pages/src/feature.ts