Skip to content

Recipe: legal-pages + text-content

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.textContent
  • 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.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:

  • text-content + 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-dev-server";
import {
createTextContentApi,
createTextContentFeature,
} from "@cosmicdrift/kumiko-bundled-features/text-content";
import { createLegalPagesFeature } from "@cosmicdrift/kumiko-bundled-features/legal-pages";
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
await runProdApp({
features: [
createTextContentFeature(),
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.textContent
extraContext: ({ db }) => ({
textContent: createTextContentApi(db),
}),
});

→ 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:
bun kumiko migrate generate # detects text-block entity → SQL migration
bun kumiko migrate 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/text-content/seeding";
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
import { createDb } from "@cosmicdrift/kumiko-framework/db";
const db = createDb(process.env.DATABASE_URL!);
await seedTextBlock(db, {
tenantId: SYSTEM_TENANT_ID,
slug: "imprint",
lang: "de",
title: "Impressum",
body: `## 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",
lang: "de",
title: "Datenschutzerklärung",
body: `## 1. Verantwortlicher
[Dein Name + Anschrift]
## 2. Erhobene Daten
[...]`,
});

→ Templates for full legally-sound texts: e-recht24.de or datenschutz-generator.de by Dr. Schwenke. See docs/plans/datenschutz/legal-artifacts.md.

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: "text-content:write:set",
payload: { slug: "imprint", lang: "de", title: "Impressum", body: "..." },
}),
});

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.textContent missingextraContext.textContent not wiredSet extraContext: ({ db }) => ({ textContent: createTextContentApi(db) })
Route returns 404 not configuredRequired block doesn’t exist or has body=nullseedTextBlock with a body string
Multi-tenant app: tenant subdomain shows an empty page(Bug regression?) Routes should ALWAYS show SYSTEM_TENANT textsThe legal-pages.integration.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

Source path: samples/recipes/legal-pages/README.md