Skip to content

Recipe: legal-pages + template-resolver

DACH compliance stack: two opt-in bundled features wired up to auto-rendered Imprint + Privacy-Policy pages, with Markdown authoring and a boot check for required content.

What the recipe demonstrates:

  • Activate both features in runProdApp({ features: [...] })
  • Required wirings: anonymousAccess + extraContext.templateResolver
  • Initial seed of the DE required blocks (Imprint + Privacy) for SYSTEM_TENANT
  • 5 integration tests proving end-to-end behavior
samples/recipes/legal-pages/
├── package.json # workspace deps
├── README.md # this file
└── src/
├── feature.ts # the two features re-exported for tests
└── __tests__/
└── feature.integration.test.ts # 5 tests: routes + boot check

feature.ts is intentionally thin (re-export). In a real app this lives in bin/main.ts (see “Integration into a real app” below).

Terminal window
# From the repo root:
bun kumiko test all samples/recipes/legal-pages/

Expected output:

Test Files 1 passed (1)
Tests 5 passed (5)

When the tests are green:

  • template-resolver + legal-pages are compatible
  • Table schema is clean via unsafeCreateEntityTable(stack.db, textBlockEntity)
  • Routes are reachable via stack.app.request("/legal/impressum")
  • Markdown rendering produces valid HTML
  • Boot check correctly detects missing blocks

Step-by-step for an existing Kumiko app (e.g. samples/showcases/your-app/):

bin/main.ts
import { runProdApp } from "@cosmicdrift/kumiko-server-runtime";
import {
createTemplateResolverApi,
createTemplateResolverFeature,
} from "@cosmicdrift/kumiko-bundled-features/template-resolver";
import { createLegalPagesFeature } from "@cosmicdrift/kumiko-bundled-features/legal-pages";
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
await runProdApp({
envSchema, // composeEnvSchema({ core: frameworkCoreEnvSchema, features })
features: [
createTemplateResolverFeature(),
createLegalPagesFeature(),
/* ... your other features */
],
// Required (1): routes run anonymous, need tenant resolution
anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
// Required (2): boot check + internal lookup use ctx.templateResolver
extraContext: ({ db, registry }) => ({
templateResolver: createTemplateResolverApi(db, registry),
}),
});

→ For host-based multi-tenant apps (like publicstatus.eu), SYSTEM_TENANT_ID always stays correct for legal-pages — the routes internally set X-Tenant: SYSTEM_TENANT_ID and override any host-based tenantResolver. This is the “1 app = X tenants = 1 imprint” decision.

Terminal window
# In the app workspace:
bunx kumiko-schema generate <name> # detects text-block entity → SQL migration
bunx kumiko-schema apply # one-time (pre-deploy step in prod)

A one-shot setup routine that runs on first boot or via the CLI:

bin/seed-legal.ts
import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/template-resolver/seeding";
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
import { createDbConnection } from "@cosmicdrift/kumiko-framework/db";
const { db } = createDbConnection({ url: process.env.DATABASE_URL! });
await seedTextBlock(db, {
tenantId: SYSTEM_TENANT_ID,
slug: "imprint",
locale: "de",
title: "Impressum",
content: `## Angaben gemäß § 5 TMG
**[Dein Name / GmbH]**
[Strasse + Nr]
[PLZ Ort]
Deutschland
## Kontakt
E-Mail: [hello@example.com](mailto:hello@example.com)
## Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV
[Dein Name, Adresse]`,
});
await seedTextBlock(db, {
tenantId: SYSTEM_TENANT_ID,
slug: "privacy",
locale: "de",
title: "Datenschutzerklärung",
content: `## 1. Verantwortlicher
[Dein Name + Anschrift]
## 2. Erhobene Daten
[...]`,
});

→ Templates for full legally-sound texts: e-recht24.de or datenschutz-generator.de by Dr. Schwenke.

With runProdApp the same blocks can come from a JSON file on every boot. legalContentSeed updates existing blocks, so the file stays authoritative:

import legalContent from "./legal-content.json" with { type: "json" };
import { legalContentSeed } from "@cosmicdrift/kumiko-bundled-features/legal-pages";
runProdApp({ /* ... */ seeds: [legalContentSeed({ blocks: legalContent })] });

The recipe mounts createLegalPagesFeature({ terms: true, wrapLayout }), so it also serves /legal/nutzungsbedingungen and /legal/terms, and wraps every page in createLegalPagesLayout({ siteName }).

After the seed these URLs are immediately live:

URLContent
https://your-app.example/legal/impressumImpressum (DE)
https://your-app.example/legal/datenschutzDatenschutzerklärung (DE)
https://your-app.example/legal/imprintImprint (EN, if seeded)
https://your-app.example/legal/privacyPrivacy Policy (EN, if seeded)

→ Footer links are set per app (legal-pages does not ship a footer component — deliberately, every app has its own layout).

5. Editing texts later (TenantAdmin maintenance)

Section titled “5. Editing texts later (TenantAdmin maintenance)”

Through the standard write API:

await fetch("/api/write", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwt}` },
body: JSON.stringify({
type: "template-resolver:write:set",
payload: { slug: "imprint", locale: "de", title: "Impressum", content: "..." },
}),
});

ACL: ["TenantAdmin", "SystemAdmin"]. Cache header public, max-age=300 — visitors see updates within 5 minutes at most.

When the required blocks (imprint/de + privacy/de) are missing from SYSTEM_TENANT:

ModeBehavior
NODE_ENV=productionApp boot throws an error with a slug list — container exits
otherwise (dev/test)console.warn with a slug list, app starts anyway

→ Safety net: no production deploy without populated legal pages.

SymptomCauseFix
Route returns 503 legal page unavailableanonymousAccess not configured in runProdAppSet anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID }
Boot check throws ctx.templateResolver missingextraContext.templateResolver not wiredSet extraContext: ({ db, registry }) => ({ templateResolver: createTemplateResolverApi(db, registry) })
Route returns 404 not configuredRequired block doesn’t exist or has content=nullseedTextBlock with a content string
Multi-tenant app: tenant subdomain shows an empty page(Bug regression?) Routes should ALWAYS show SYSTEM_TENANT textsThe legal-pages.integration.test.ts test “SYSTEM_TENANT routing” covers this — should be green
<script> tags in a Markdown body land 1:1 in the HTMLDeliberately accepted right now — see legal-pages/README.md XSS sectionDOMPurify is a Phase 2 once a multi-author setup arrives

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):

// Legal-pages sample
//
// DACH apps (DE/AT/CH) are required to make an imprint (TMG/DDG §5) and
// a privacy policy (GDPR Art. 13) publicly accessible.
// That is 1) identical for every app and 2) annoying to build
// by hand per app.
//
// Solution: combine two opt-in bundled features:
//
// - `template-resolver` — the content store (entity `template-resource`,
// kind `text-block` with slug+locale+title+content, scoped per tenant).
// Also usable for FAQ, About, ToS, marketing snippets — not
// legal-specific.
//
// - `legal-pages` — opt-in wrapper on top of it that registers four fixed public
// routes (`/legal/impressum`, `/legal/datenschutz`, `/legal/imprint`,
// `/legal/privacy`) and renders Markdown→HTML. Plus a
// boot check that fails hard in production when the DE required blocks
// are missing.
//
// Tenant model: 1 app = X tenants = 1 imprint. All subdomains
// share the SYSTEM_TENANT_ID version. If you need per-tenant imprints,
// you have to put your own routing in front or use the by-slug
// query with a tenant-specific tenantId.
//
// Prerequisites for production:
// - `anonymousAccess` must be configured in runProdApp/runDevApp
// (defaultTenantId = SYSTEM_TENANT_ID), otherwise the
// legal-pages routes answer with 503
// - `extraContext.templateResolver = createTemplateResolverApi(db, registry)` must be
// wired, otherwise the boot check throws with a wiring hint
// - On first boot the TextBlocks must be seeded —
// template-resolver/seeding `seedTextBlock` or via API
// `template-resolver:write:set` with a TenantAdmin token
import {
createLegalPagesFeature,
createLegalPagesLayout,
LEGAL_REQUIRED_BLOCKS,
LEGAL_ROUTES,
LEGAL_TERMS_ROUTES,
} from "@cosmicdrift/kumiko-bundled-features/legal-pages";
import { createTemplateResolverFeature } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
// Beide Features aktivieren — template-resolver ist Foundation, legal-pages
// requires sie. r.requires({ features: ["template-resolver"] }) greift automatisch im
// legal-pages-Feature.
export const templateResolverFeature = createTemplateResolverFeature();
// `terms: true` adds the opt-in terms routes; the layout puts the app's chrome
// around every legal page.
export const legalPagesFeature = createLegalPagesFeature({
terms: true,
wrapLayout: createLegalPagesLayout({
siteName: "Sample App",
footerHtml: (lang) =>
lang === "de" ? "<footer>Sample App</footer>" : "<footer>Sample App (en)</footer>",
}),
});
// Re-exports for tests + other demos
export { LEGAL_REQUIRED_BLOCKS, LEGAL_ROUTES, LEGAL_TERMS_ROUTES };

📄 On GitHub: samples/recipes/legal-pages/src/feature.ts