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
Layout
Section titled “Layout”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 checkfeature.ts is intentionally thin (re-export). In a real app this
lives in bin/main.ts (see “Integration into a real app” below).
Run tests
Section titled “Run tests”# 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
Integration into a real app
Section titled “Integration into a real app”Step-by-step for an existing Kumiko app (e.g.
samples/showcases/your-app/):
1. Activate features in runProdApp
Section titled “1. Activate features in runProdApp”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.
2. Create the table
Section titled “2. Create the table”# In the app workspace:bunx kumiko-schema generate <name> # detects text-block entity → SQL migrationbunx kumiko-schema apply # one-time (pre-deploy step in prod)3. Initial seed of the required blocks
Section titled “3. Initial seed of the required blocks”A one-shot setup routine that runs on first boot or via the CLI:
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 }).
4. Visit the pages
Section titled “4. Visit the pages”After the seed these URLs are immediately live:
| URL | Content |
|---|---|
https://your-app.example/legal/impressum | Impressum (DE) |
https://your-app.example/legal/datenschutz | Datenschutzerklärung (DE) |
https://your-app.example/legal/imprint | Imprint (EN, if seeded) |
https://your-app.example/legal/privacy | Privacy 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.
Boot-check behavior
Section titled “Boot-check behavior”When the required blocks (imprint/de + privacy/de) are missing
from SYSTEM_TENANT:
| Mode | Behavior |
|---|---|
NODE_ENV=production | App 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.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause | Fix |
|---|---|---|
Route returns 503 legal page unavailable | anonymousAccess not configured in runProdApp | Set anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID } |
Boot check throws ctx.templateResolver missing | extraContext.templateResolver not wired | Set extraContext: ({ db, registry }) => ({ templateResolver: createTemplateResolverApi(db, registry) }) |
Route returns 404 not configured | Required block doesn’t exist or has content=null | seedTextBlock with a content string |
| Multi-tenant app: tenant subdomain shows an empty page | (Bug regression?) Routes should ALWAYS show SYSTEM_TENANT texts | The 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 HTML | Deliberately accepted right now — see legal-pages/README.md XSS section | DOMPurify is a Phase 2 once a multi-author setup arrives |
Cross-refs
Section titled “Cross-refs”- packages/bundled-features/src/template-resolver/README.md — content store
- packages/bundled-features/src/legal-pages/README.md — DACH compliance wrapper
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):
// 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 demosexport { LEGAL_REQUIRED_BLOCKS, LEGAL_ROUTES, LEGAL_TERMS_ROUTES };📄 On GitHub: samples/recipes/legal-pages/src/feature.ts