@cosmicdrift/kumiko-bundled-features 0.50.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +8 -6
  2. package/src/config/__tests__/backing-secrets.integration.test.ts +188 -0
  3. package/src/config/__tests__/config.integration.test.ts +60 -0
  4. package/src/config/feature.ts +5 -2
  5. package/src/config/handlers/cascade.query.ts +4 -1
  6. package/src/config/handlers/readiness.query.ts +1 -0
  7. package/src/config/handlers/reset.write.ts +23 -2
  8. package/src/config/handlers/set.write.ts +36 -2
  9. package/src/config/handlers/values.query.ts +5 -1
  10. package/src/config/resolver.ts +93 -3
  11. package/src/config/write-helpers.ts +37 -0
  12. package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +162 -0
  13. package/src/jobs/feature.ts +13 -0
  14. package/src/jobs/handlers/projection-rebuild.job.ts +36 -0
  15. package/src/legal-pages/README.md +16 -13
  16. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +15 -8
  17. package/src/legal-pages/feature.ts +9 -4
  18. package/src/legal-pages/markdown.ts +6 -56
  19. package/src/legal-pages/security-headers.ts +1 -0
  20. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +536 -0
  21. package/src/managed-pages/branding.ts +142 -0
  22. package/src/managed-pages/css-gate.ts +24 -0
  23. package/src/managed-pages/feature.ts +246 -0
  24. package/src/managed-pages/handlers/branding.query.ts +30 -0
  25. package/src/managed-pages/handlers/by-slug.query.ts +35 -0
  26. package/src/managed-pages/handlers/set.write.ts +113 -0
  27. package/src/managed-pages/index.ts +30 -0
  28. package/src/managed-pages/screens/branding-screen.ts +85 -0
  29. package/src/managed-pages/screens/page-screens.ts +82 -0
  30. package/src/managed-pages/seeding.ts +99 -0
  31. package/src/managed-pages/table.ts +58 -0
  32. package/src/page-render/__tests__/branding.test.ts +57 -0
  33. package/src/page-render/__tests__/css-sanitize.test.ts +215 -0
  34. package/src/page-render/__tests__/markdown.test.ts +41 -0
  35. package/src/page-render/branding.ts +99 -0
  36. package/src/page-render/css-sanitize.ts +344 -0
  37. package/src/page-render/index.ts +13 -0
  38. package/src/page-render/layout.ts +100 -0
  39. package/src/page-render/markdown.ts +39 -0
  40. package/src/page-render/security-headers.ts +16 -0
@@ -0,0 +1,24 @@
1
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { MANAGED_PAGES_CSS_FEATURE } from "./branding";
3
+
4
+ // Per-tenant toggle gate for the managed-pages custom-CSS capability. Declares
5
+ // no handlers/entities — composing it simply registers `managed-pages-css` as a
6
+ // `toggleable` feature (default OFF), which an operator/tier can enable per
7
+ // tenant via feature-toggles. managed-pages' branding query reads
8
+ // `ctx.hasFeature("managed-pages-css")` and only emits raw tenant CSS when this
9
+ // toggle is on AND the app passed `allowCustomCss: true`.
10
+ //
11
+ // Without a feature-toggles/tier-engine runtime wired, ctx.hasFeature returns
12
+ // true (apps without tier-cuts treat all features on), so `allowCustomCss`
13
+ // alone governs. To per-tenant tier-gate CSS-inject, compose this feature AND
14
+ // wire feature-toggles; the render-time sanitizer is the safety boundary either
15
+ // way. Compose alongside managed-pages (it `requires` it).
16
+ export function createManagedPagesCssFeature(): FeatureDefinition {
17
+ return defineFeature(MANAGED_PAGES_CSS_FEATURE, (r) => {
18
+ r.describe(
19
+ 'Per-tenant toggle gate for the managed-pages custom-CSS (raw tenant CSS-inject) capability. Handler-less: composing it makes `managed-pages-css` a toggleable feature defaulting OFF, so an operator/tier can grant raw CSS-inject per tenant. managed-pages reads `ctx.hasFeature("managed-pages-css")` in its branding query and emits tenant CSS only when this toggle is on AND the app passed `allowCustomCss: true`. The tenant CSS is allowlist-sanitized + scoped at render regardless; this gate is the commercial/operator control, not the safety boundary.',
20
+ );
21
+ r.requires("managed-pages");
22
+ r.toggleable({ default: false });
23
+ });
24
+ }
@@ -0,0 +1,246 @@
1
+ import {
2
+ defineEntityCreateHandler,
3
+ defineEntityDeleteHandler,
4
+ defineEntityDetailHandler,
5
+ defineEntityListHandler,
6
+ defineEntityUpdateHandler,
7
+ defineFeature,
8
+ type FeatureDefinition,
9
+ } from "@cosmicdrift/kumiko-framework/engine";
10
+ import {
11
+ type BrandingTokens,
12
+ EMPTY_BRANDING,
13
+ renderSafeMarkdown,
14
+ securePageHeaders,
15
+ wrapInLayout,
16
+ } from "../page-render";
17
+ import { BRANDING_KEYS, BRANDING_QUERY_QN, CUSTOM_CSS_KEY, coerceBranding } from "./branding";
18
+ import { createBrandingQuery } from "./handlers/branding.query";
19
+ import { bySlugQuery } from "./handlers/by-slug.query";
20
+ import { setWrite } from "./handlers/set.write";
21
+ import { createBrandingSettingsScreen } from "./screens/branding-screen";
22
+ import { pageEditScreen, pageListScreen } from "./screens/page-screens";
23
+ import { pageEntity } from "./table";
24
+
25
+ // Admin-Authoring läuft als TenantAdmin (self-service) oder SystemAdmin
26
+ // (app-weite Pages). Spiegelt set.write's ACL — Apps mit eigenem Rollen-
27
+ // Alias (publicstatus = "Admin") müssen TenantAdmin granten/mappen.
28
+ const ADMIN_ACCESS = { roles: ["TenantAdmin", "SystemAdmin"] } as const;
29
+
30
+ // QN-Konstante als dokumentierter Public-Contract — der Render-Pfad ruft
31
+ // die by-slug-Query via internem app.fetch (kein Code-Import des Handlers,
32
+ // symmetrisch zum legal-pages-Muster).
33
+ const BY_SLUG_QN = "managed-pages:query:by-slug";
34
+
35
+ // Wire-Body-Shape von /api/query — das was bySlugQuery returnt (published-only).
36
+ type ByslugQueryBody = {
37
+ data: {
38
+ title: string;
39
+ body: string;
40
+ lang: string;
41
+ description: string | null;
42
+ ogImage: string | null;
43
+ } | null;
44
+ };
45
+
46
+ // Parse the branding query's `{ data }` envelope into BrandingTokens, never
47
+ // throwing: a non-ok status or malformed body degrades to the unbranded
48
+ // default (branding is decoration, not a hard dependency of the page render).
49
+ async function readBrandingResponse(res: Response): Promise<BrandingTokens> {
50
+ if (!res.ok) return EMPTY_BRANDING;
51
+ try {
52
+ const body: { data?: unknown } = await res.json();
53
+ return coerceBranding(body.data);
54
+ } catch {
55
+ return EMPTY_BRANDING;
56
+ }
57
+ }
58
+
59
+ export type ManagedPagesWrapLayout = (opts: {
60
+ readonly title: string;
61
+ readonly bodyHtml: string;
62
+ readonly lang: string;
63
+ readonly slug: string;
64
+ readonly description: string | null;
65
+ readonly ogImage: string | null;
66
+ // Per-tenant branding tokens resolved at render time (accent color, logo,
67
+ // layout preset, …). A custom wrapLayout may apply them however it likes;
68
+ // the default skeleton emits scoped :root vars + a logo/title header.
69
+ readonly branding: BrandingTokens;
70
+ }) => string;
71
+
72
+ export type ManagedPagesOptions = {
73
+ /** Host → tenantId für anonyme per-Tenant-Auslieferung. NULL → 404 (kein
74
+ * Tenant für diesen Host). Apex-/Marketing-Apps geben hier ihre Subdomain-
75
+ * /Custom-Domain-Auflösung rein; single-tenant gibt konstant einen
76
+ * tenantId (oder SYSTEM_TENANT_ID) zurück. Erforderlich, weil der Apex-
77
+ * Host keinen ambient ctx.tenantId hat. */
78
+ readonly resolveApexTenant: (host: string) => Promise<string | null> | string | null;
79
+ /** Custom Layout-Wrapper (Branding/Chrome). Default: minimaler
80
+ * page-render-Skeleton. Erhält slug + SEO-Meta zur freien Nutzung.
81
+ * **`branding` ist RAW, untrusted tenant input.** `title`/`description`
82
+ * sind am Write nur längen-gecappt, NICHT HTML-escaped; `customCss` ist
83
+ * ungesanitet. Der Wrapper MUSS sie über die Boundary-Helper emittieren
84
+ * (alle re-exported von `@cosmicdrift/kumiko-bundled-features/managed-pages`):
85
+ * `brandingHeaderHtml(branding)` + `brandingStyleBlock(branding)` (escapen
86
+ * Header/Theme) und — mit allowCustomCss — `tenantStyleBlock(branding.
87
+ * customCss)` ins `<head>` plus `TENANT_CONTENT_ATTR` am Body-Container. Ein
88
+ * Custom-Wrapper der `branding.title` selbst interpoliert ist stored XSS;
89
+ * der Default-Wrapper nutzt durchweg die Helper. */
90
+ readonly wrapLayout?: ManagedPagesWrapLayout;
91
+ /** Basis-Pfad der Page-Routes. Default "/p" → GET /p/:slug. */
92
+ readonly basePath?: string;
93
+ /** Default-Sprache wenn `?lang=` fehlt. Default "en". */
94
+ readonly defaultLang?: string;
95
+ /** Aktiviert die per-Tenant Custom-CSS-Capability (raw, untrusted): ein
96
+ * `branding-custom-css` Config-Key + ein CSS-Feld im Branding-Editor + die
97
+ * Render-Emission als scoped, allowlist-sanitized `<style data-tenant-css>`.
98
+ * Default **false** (fail-closed — opt-in für untrusted-Tenant-Input). Auch
99
+ * wenn true, wird die Emission zusätzlich per-Tenant über das
100
+ * `managed-pages-css`-Toggle (createManagedPagesCssFeature) gegated, sobald
101
+ * ein feature-toggles/tier-engine-Runtime verdrahtet ist. Der Render-time-
102
+ * Sanitizer (page-render/css-sanitize) ist der Safety-Boundary. */
103
+ readonly allowCustomCss?: boolean;
104
+ };
105
+
106
+ // managed-pages — vom Tenant editierbare, server-gerenderte Public-Pages.
107
+ // Generalisiert das legal-pages-Render-Muster: eine dynamische Slug-Route
108
+ // (`GET /p/:slug`), die den Tenant aus dem Host auflöst (resolveApexTenant),
109
+ // die published Page lädt, Markdown gehärtet rendert (page-render) und über
110
+ // einen optionalen wrapLayout in Chrome legt. Drafts → 404. Per-Tenant-
111
+ // Content wird per `Vary: Host` cache-isoliert.
112
+ //
113
+ // Admin-Authoring: registriert entityList + entityEdit für `page` (TenantAdmin/
114
+ // SystemAdmin) + die Convention-CRUD-Handler die der Renderer per Konvention
115
+ // dispatcht. Die Screens MÜSSEN hier liegen (Boot-Validator verlangt entity-Ref
116
+ // same-feature); Nav/Workspace bleibt App-Sache.
117
+ //
118
+ // Voraussetzungen am App-Bootstrap:
119
+ // • anonymousAccess so verdrahtet, dass der X-Tenant-Header honoriert wird
120
+ // — Multi-Tenant nutzt einen tenantResolver (oder keinen defaultTenantId).
121
+ // Ein fixer defaultTenantId lockt Single-Tenant und lehnt den per-Page
122
+ // gesetzten X-Tenant mit 400 tenant_mismatch ab.
123
+ // • Admin-UI: die App zeigt via `r.nav({ screen: "managed-pages:screen:
124
+ // page-list" })` + Workspace-Eintrag auf die hier deklarierten Screens
125
+ // (cross-feature Nav→Screen ist global-validiert) und grantet ihren Admins
126
+ // TenantAdmin. Ohne Nav bleiben die Screens dormant — kein Leak in
127
+ // flat-nav-Apps die managed-pages nur zum Public-Render nutzen.
128
+ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDefinition {
129
+ const wrapLayout: ManagedPagesWrapLayout =
130
+ opts.wrapLayout ??
131
+ ((o) =>
132
+ wrapInLayout({
133
+ title: o.title,
134
+ bodyHtml: o.bodyHtml,
135
+ lang: o.lang,
136
+ description: o.description,
137
+ branding: o.branding,
138
+ }));
139
+ const basePath = opts.basePath ?? "/p";
140
+ const defaultLang = opts.defaultLang ?? "en";
141
+ const allowCustomCss = opts.allowCustomCss ?? false;
142
+
143
+ return defineFeature("managed-pages", (r) => {
144
+ r.describe(
145
+ "Tenant-editable, server-rendered public pages with per-tenant branding. Stores one Markdown `page` per `(tenantId, slug, lang)` in the `read_pages` entity table with a `published` gate plus `description`/`ogImage` SEO meta. Registers an anonymous `GET {basePath}/:slug` route that resolves the tenant from the request Host via the app-supplied `resolveApexTenant`, serves only published pages (drafts → 404), renders Markdown through the hardened `page-render` core, and isolates per-tenant content with `Vary: Host`. Ships TenantAdmin/SystemAdmin admin screens (`entityList` + `entityEdit`) backed by convention CRUD handlers (`managed-pages:write:page:{create,update,delete}`, `managed-pages:query:page:{list,detail}`); the app wires nav/workspace onto `managed-pages:screen:page-list`. Branding (via `config`, scope tenant): `branding-{title,description,site-url,accent-color,logo-url,layout-preset}` keys with write-time validation (hex color, https URLs), a `configEdit` self-service screen (`managed-pages:screen:branding-settings`), and a `managed-pages:query:branding` read that the render path applies as scoped `:root` CSS vars + a logo/title header. Also exposes `managed-pages:write:set` (idempotent slug-keyed upsert, SystemAdmin cross-tenant via `tenantIdOverride`) as a provisioning API. Requires `config` + `anonymousAccess` wired at app bootstrap.",
146
+ );
147
+ r.requires("config");
148
+ r.entity("page", pageEntity);
149
+
150
+ // Per-tenant branding config keys (scope: tenant). Write-validated via
151
+ // keyDef.pattern (hex / https) — see branding.ts. read:all so the
152
+ // anonymous render path may resolve them. The raw-CSS key is added only
153
+ // when allowCustomCss (fail-closed: no key/editor when the capability off).
154
+ r.config({ keys: allowCustomCss ? { ...BRANDING_KEYS, ...CUSTOM_CSS_KEY } : BRANDING_KEYS });
155
+
156
+ const handlers = { set: r.writeHandler(setWrite) };
157
+ const queries = {
158
+ bySlug: r.queryHandler(bySlugQuery),
159
+ branding: r.queryHandler(createBrandingQuery({ allowCustomCss })),
160
+ };
161
+
162
+ // Convention-CRUD hinter den Admin-Screens: entityEdit/entityList
163
+ // dispatchen per Konvention `managed-pages:write:page:{create,update,
164
+ // delete}` + `managed-pages:query:page:{list,detail}`. `set` (oben)
165
+ // wird davon NICHT genutzt und bleibt als Provisioning-API erhalten.
166
+ r.writeHandler(defineEntityCreateHandler("page", pageEntity, { access: ADMIN_ACCESS }));
167
+ r.writeHandler(defineEntityUpdateHandler("page", pageEntity, { access: ADMIN_ACCESS }));
168
+ r.writeHandler(defineEntityDeleteHandler("page", pageEntity, { access: ADMIN_ACCESS }));
169
+ r.queryHandler(defineEntityListHandler("page", pageEntity, { access: ADMIN_ACCESS }));
170
+ r.queryHandler(defineEntityDetailHandler("page", pageEntity, { access: ADMIN_ACCESS }));
171
+
172
+ r.screen(pageListScreen);
173
+ r.screen(pageEditScreen);
174
+ r.screen(createBrandingSettingsScreen({ allowCustomCss }));
175
+
176
+ r.httpRoute({
177
+ method: "GET",
178
+ path: `${basePath}/:slug`,
179
+ anonymous: true,
180
+ handler: async (c, { app }) => {
181
+ // `param("slug")` ist string|undefined, weil `path` ein computed
182
+ // Template ist (Hono inferiert `:slug` nur aus String-Literalen).
183
+ const slug = c.req.param("slug");
184
+ if (!slug) return c.text("not found", 404);
185
+ const lang = c.req.query("lang") || defaultLang;
186
+ const url = new URL(c.req.url);
187
+ // Host-Header zuerst (prod hinter Proxy), URL-Host als Fallback.
188
+ const host = c.req.header("host") ?? url.host;
189
+
190
+ const tenantId = await opts.resolveApexTenant(host);
191
+ if (!tenantId) return c.text("not found", 404);
192
+
193
+ const queryHeaders = { "content-type": "application/json", "X-Tenant": tenantId };
194
+ const queryUrl = `${url.origin}/api/query`;
195
+ const queryReq = (type: string, payload: unknown) =>
196
+ app.fetch(
197
+ new Request(queryUrl, {
198
+ method: "POST",
199
+ headers: queryHeaders,
200
+ body: JSON.stringify({ type, payload }),
201
+ }),
202
+ );
203
+
204
+ // Page + branding read in parallel (same in-process app, same
205
+ // X-Tenant). Branding is decoration → a failed/empty branding read
206
+ // degrades to the unbranded default, it never blocks the page.
207
+ const [pageRes, brandingRes] = await Promise.all([
208
+ queryReq(BY_SLUG_QN, { slug, lang }),
209
+ queryReq(BRANDING_QUERY_QN, {}),
210
+ ]);
211
+ if (!pageRes.ok) return c.text("page unavailable", 503);
212
+
213
+ const body: ByslugQueryBody = await pageRes.json();
214
+ const data = body.data;
215
+ if (!data) return c.text("not found", 404);
216
+
217
+ const branding = await readBrandingResponse(brandingRes);
218
+
219
+ const html = wrapLayout({
220
+ title: data.title,
221
+ bodyHtml: renderSafeMarkdown(data.body),
222
+ lang: data.lang,
223
+ slug,
224
+ description: data.description,
225
+ ogImage: data.ogImage,
226
+ branding,
227
+ });
228
+
229
+ // Vary: Host — per-Tenant-Content darf nicht von einem shared CDN
230
+ // nur unter dem Pfad gecached werden (sonst Tenant A's Page auf
231
+ // Tenant B's Domain). Cache keyed mit auf den Host.
232
+ return c.body(
233
+ html,
234
+ 200,
235
+ securePageHeaders({
236
+ "content-type": "text/html; charset=utf-8",
237
+ "cache-control": "public, max-age=300",
238
+ vary: "Host",
239
+ }),
240
+ );
241
+ },
242
+ });
243
+
244
+ return { handlers, queries };
245
+ });
246
+ }
@@ -0,0 +1,30 @@
1
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { z } from "zod";
3
+ import { MANAGED_PAGES_CSS_FEATURE, readBranding, readCustomCss } from "../branding";
4
+
5
+ // Public branding read for the server-render path. Anonymous-capable: the
6
+ // render route reaches this via internal app.fetch with X-Tenant = host-
7
+ // resolved tenant, so `query.user.tenantId` (and thus `ctx.config`, which is
8
+ // minted from it) carries that tenant — identical to how by-slug resolves the
9
+ // page. Branding keys are read:all → an anonymous caller may read them (they
10
+ // are rendered on a public page). No payload: the tenant is implicit.
11
+ //
12
+ // custom CSS is gated twice: the app must opt in (allowCustomCss, baked into
13
+ // this factory) AND the per-tenant `managed-pages-css` toggle must be on
14
+ // (ctx.hasFeature). Note ctx.hasFeature returns true when no feature-toggles/
15
+ // tier-engine runtime is wired, so without that runtime allowCustomCss alone
16
+ // governs. Even when emitted, the value is RAW — render re-sanitizes it.
17
+ export function createBrandingQuery(opts: { readonly allowCustomCss: boolean }) {
18
+ return defineQueryHandler({
19
+ name: "branding",
20
+ schema: z.object({}),
21
+ access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
22
+ handler: async (_query, ctx) => {
23
+ const base = await readBranding(ctx.config);
24
+ if (!opts.allowCustomCss || !ctx.config || !ctx.hasFeature(MANAGED_PAGES_CSS_FEATURE)) {
25
+ return base;
26
+ }
27
+ return { ...base, customCss: await readCustomCss(ctx.config) };
28
+ },
29
+ });
30
+ }
@@ -0,0 +1,35 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { z } from "zod";
4
+ import { type PageRow, pagesTable } from "../table";
5
+
6
+ // Public-Read by (tenantId, slug, lang). Anonymous-capable (Landing-/
7
+ // Marketing-Pages). Tenant kommt aus query.user.tenantId — am Render-Pfad
8
+ // via X-Tenant = Host-resolved-tenant gesetzt. Liefert NUR published Pages
9
+ // mit Body: Drafts + leere Pages sind für anonyme Besucher unsichtbar
10
+ // (Route → 404). Admin-Editing nutzt die Entity-List/Edit-Screens, nicht
11
+ // diese Query.
12
+ export const bySlugQuery = defineQueryHandler({
13
+ name: "by-slug",
14
+ schema: z.object({
15
+ slug: z.string().min(1).max(64),
16
+ lang: z.string().min(2).max(8),
17
+ }),
18
+ access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
19
+ handler: async (query, ctx) => {
20
+ const row = await fetchOne<PageRow>(ctx.db, pagesTable, {
21
+ tenantId: query.user.tenantId,
22
+ slug: query.payload.slug,
23
+ lang: query.payload.lang,
24
+ });
25
+ if (!row?.published || !row.body) return null;
26
+ return {
27
+ slug: row.slug,
28
+ lang: row.lang,
29
+ title: row.title,
30
+ body: row.body,
31
+ description: row.description,
32
+ ogImage: row.ogImage,
33
+ };
34
+ },
35
+ });
@@ -0,0 +1,113 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
3
+ import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { AccessDeniedError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
5
+ import { z } from "zod";
6
+ import { type PageRow, pageEntity, pagesTable } from "../table";
7
+
8
+ const slugSchema = z
9
+ .string()
10
+ .min(1)
11
+ .max(64)
12
+ .regex(/^[a-z0-9][a-z0-9-]*$/, "slug must be kebab-case (lowercase, digits, dashes)");
13
+
14
+ const langSchema = z
15
+ .string()
16
+ .min(2)
17
+ .max(8)
18
+ .regex(/^[a-z]{2}(-[a-z]{2})?$/i, "lang must be ISO 639-1 (e.g. de, en, en-us)");
19
+
20
+ const executor = createEventStoreExecutor(pagesTable, pageEntity, { entityName: "page" });
21
+
22
+ // Upsert einer Page — eine Operation pro (tenantId, slug, lang). Tenant-
23
+ // Scope default aus event.user; SystemAdmin kann via `tenantIdOverride`
24
+ // für einen anderen Tenant schreiben (typisch SYSTEM_TENANT_ID für app-
25
+ // weite Pages). published/description/ogImage werden bei Update preserved
26
+ // wenn im Payload weggelassen (undefined) — damit ein Publish-Toggle nicht
27
+ // den Body überschreiben muss und umgekehrt.
28
+ export const setWrite = defineWriteHandler({
29
+ name: "set",
30
+ schema: z.object({
31
+ slug: slugSchema,
32
+ lang: langSchema,
33
+ title: z.string().min(1).max(200),
34
+ body: z.string().max(100_000).nullable(),
35
+ description: z.string().max(500).nullable().optional(),
36
+ ogImage: z.string().max(2000).nullable().optional(),
37
+ published: z.boolean().optional(),
38
+ /** Cross-tenant write — nur SystemAdmin (z.B. SYSTEM_TENANT_ID-Pages). */
39
+ tenantIdOverride: z.string().min(1).optional(),
40
+ }),
41
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
42
+ handler: async (event, ctx) => {
43
+ const db = ctx.db;
44
+ const override = event.payload.tenantIdOverride;
45
+ if (override !== undefined && !event.user.roles.includes("SystemAdmin")) {
46
+ return writeFailure(
47
+ new AccessDeniedError({
48
+ i18nKey: "managedPages.errors.tenantOverrideRequiresSystemAdmin",
49
+ details: { reason: "tenant_override_requires_system_admin" },
50
+ }),
51
+ );
52
+ }
53
+ const tenantId = override ?? event.user.tenantId;
54
+ // Bei Override muss der executor-user-Context auf den ziel-tenant
55
+ // umgestellt werden, sonst läuft getStreamVersion gegen user.tenantId
56
+ // statt tenantId → version_conflict trotz vorhandener projection-row.
57
+ const executorUser =
58
+ override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user; // @cast-boundary engine-bridge
59
+
60
+ const existing = await fetchOne<PageRow>(db, pagesTable, {
61
+ tenantId,
62
+ slug: event.payload.slug,
63
+ lang: event.payload.lang,
64
+ });
65
+
66
+ if (existing) {
67
+ const result = await executor.update(
68
+ {
69
+ id: existing.id,
70
+ version: existing.version,
71
+ changes: {
72
+ title: event.payload.title,
73
+ body: event.payload.body,
74
+ description:
75
+ event.payload.description !== undefined
76
+ ? event.payload.description
77
+ : existing.description,
78
+ ogImage: event.payload.ogImage !== undefined ? event.payload.ogImage : existing.ogImage,
79
+ published:
80
+ event.payload.published !== undefined ? event.payload.published : existing.published,
81
+ },
82
+ },
83
+ executorUser,
84
+ db,
85
+ );
86
+ if (!result.isSuccess) return result;
87
+ return {
88
+ isSuccess: true as const,
89
+ data: { slug: event.payload.slug, lang: event.payload.lang, isNew: false },
90
+ };
91
+ }
92
+
93
+ const result = await executor.create(
94
+ {
95
+ slug: event.payload.slug,
96
+ lang: event.payload.lang,
97
+ title: event.payload.title,
98
+ body: event.payload.body,
99
+ description: event.payload.description ?? null,
100
+ ogImage: event.payload.ogImage ?? null,
101
+ published: event.payload.published ?? false,
102
+ tenantId,
103
+ },
104
+ executorUser,
105
+ db,
106
+ );
107
+ if (!result.isSuccess) return result;
108
+ return {
109
+ isSuccess: true as const,
110
+ data: { slug: event.payload.slug, lang: event.payload.lang, isNew: true },
111
+ };
112
+ },
113
+ });
@@ -0,0 +1,30 @@
1
+ // Render-boundary primitives for a custom wrapLayout. The `branding` it
2
+ // receives is RAW, untrusted tenant input: `title`/`description` are only
3
+ // length-capped at write (NOT HTML-escaped), and `customCss` is unsanitized.
4
+ // Emit them only through these helpers, which escape/sanitize at the boundary:
5
+ // • `brandingHeaderHtml(branding)` / `brandingStyleBlock(branding)` — escaped
6
+ // logo/title header + scoped `:root` theme vars (the default skeleton uses
7
+ // these). Hand-rolling `<h1>${branding.title}</h1>` instead is stored XSS.
8
+ // • `tenantStyleBlock(branding.customCss)` — the scope-baked, allowlist-
9
+ // sanitized, contained `<style>` block; wrap content in `TENANT_CONTENT_ATTR`.
10
+ // `sanitizeTenantCss` is the low-level escape hatch (you supply the scope).
11
+ export {
12
+ type BrandingTokens,
13
+ brandingHeaderHtml,
14
+ brandingStyleBlock,
15
+ EMPTY_BRANDING,
16
+ sanitizeTenantCss,
17
+ TENANT_CONTENT_ATTR,
18
+ tenantStyleBlock,
19
+ } from "../page-render";
20
+ // BRANDING_QN: the qualified config-key names a consumer writes branding to
21
+ // (`config:write:set`) — single source for the `managed-pages:config:branding-*`
22
+ // strings, so apps + the per-tenant migration never hardcode them.
23
+ export { BRANDING_QN, MANAGED_PAGES_CSS_FEATURE } from "./branding";
24
+ export { createManagedPagesCssFeature } from "./css-gate";
25
+ export {
26
+ createManagedPagesFeature,
27
+ type ManagedPagesOptions,
28
+ type ManagedPagesWrapLayout,
29
+ } from "./feature";
30
+ export { type PageRow, pageEntity, pagesTable } from "./table";
@@ -0,0 +1,85 @@
1
+ import {
2
+ type ConfigEditScreenDefinition,
3
+ createSelectField,
4
+ createTextField,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { BRANDING_QN, LAYOUT_PRESETS } from "../branding";
7
+
8
+ // Tenant self-service branding editor. A `configEdit` screen (no entity
9
+ // table) — the renderer loads config:query:values, maps the form fields via
10
+ // `configKeys` to the qualified config keys, and submits config:write:set per
11
+ // key on save (where the keyDef.pattern gate runs). scope:tenant → writes land
12
+ // on the acting admin's tenant. Nav/workspace placement stays App-Sache, like
13
+ // the page screens; without an `r.nav` pointing here the screen is dormant.
14
+ //
15
+ // Roles include "Admin" (App-Repos like publicstatus) alongside "TenantAdmin"
16
+ // (bundled-features) — the access.admin config preset writes both, so editing
17
+ // works in either role world (Role-Naming-Drift).
18
+ const ADMIN_ROLES = ["TenantAdmin", "Admin", "SystemAdmin"] as const;
19
+
20
+ // Factory so the raw-CSS field/key only exist when the app opted into
21
+ // allowCustomCss (fail-closed: no CSS editor when the capability is off). The
22
+ // CSS lands in `branding-custom-css` (registered conditionally by the feature);
23
+ // the write-time pattern only caps length, the render-time sanitizer is the
24
+ // allowlist gate.
25
+ export function createBrandingSettingsScreen(opts: {
26
+ readonly allowCustomCss: boolean;
27
+ }): ConfigEditScreenDefinition {
28
+ const base: ConfigEditScreenDefinition = {
29
+ id: "branding-settings",
30
+ type: "configEdit",
31
+ scope: "tenant",
32
+ configKeys: {
33
+ title: BRANDING_QN.title,
34
+ description: BRANDING_QN.description,
35
+ siteUrl: BRANDING_QN.siteUrl,
36
+ accentColor: BRANDING_QN.accentColor,
37
+ logoUrl: BRANDING_QN.logoUrl,
38
+ layoutPreset: BRANDING_QN.layoutPreset,
39
+ },
40
+ fields: {
41
+ title: createTextField({ maxLength: 200 }),
42
+ description: createTextField({ maxLength: 500, multiline: { rows: 3 } }),
43
+ siteUrl: createTextField({ maxLength: 2000, format: "url" }),
44
+ accentColor: createTextField({ maxLength: 9 }),
45
+ logoUrl: createTextField({ maxLength: 2000, format: "url" }),
46
+ layoutPreset: createSelectField({ options: LAYOUT_PRESETS }),
47
+ },
48
+ layout: {
49
+ sections: [
50
+ {
51
+ title: "managed-pages:branding.section.identity",
52
+ columns: 2,
53
+ fields: [
54
+ { field: "title", span: 1 },
55
+ { field: "layoutPreset", span: 1 },
56
+ { field: "description", span: 2 },
57
+ { field: "siteUrl", span: 1 },
58
+ { field: "logoUrl", span: 1 },
59
+ { field: "accentColor", span: 1 },
60
+ ],
61
+ },
62
+ ],
63
+ },
64
+ access: { roles: ADMIN_ROLES },
65
+ };
66
+ if (!opts.allowCustomCss) return base;
67
+ return {
68
+ ...base,
69
+ configKeys: { ...base.configKeys, customCss: BRANDING_QN.customCss },
70
+ fields: {
71
+ ...base.fields,
72
+ customCss: createTextField({ maxLength: 8000, multiline: { rows: 12 } }),
73
+ },
74
+ layout: {
75
+ sections: [
76
+ ...base.layout.sections,
77
+ {
78
+ title: "managed-pages:branding.section.custom-css",
79
+ columns: 1,
80
+ fields: [{ field: "customCss", span: 1 }],
81
+ },
82
+ ],
83
+ },
84
+ };
85
+ }
@@ -0,0 +1,82 @@
1
+ import type {
2
+ EntityEditScreenDefinition,
3
+ EntityListScreenDefinition,
4
+ } from "@cosmicdrift/kumiko-framework/engine";
5
+
6
+ // Admin-Authoring-Screens für die `page`-Entity. Reine Daten (JSON-safe) —
7
+ // der generische DataTable-/Form-Renderer mountet sie, kein React-Component
8
+ // nötig. Beide MÜSSEN im managed-pages-Feature registriert werden (nicht in
9
+ // der App): der Boot-Validator verlangt, dass `entity: "page"` im selben
10
+ // Feature deklariert ist wie der Screen. Nav/Workspace bleibt App-Sache
11
+ // (placement-spezifisch, `default`-Workspace ist ein App-Singleton) — die
12
+ // App zeigt via `r.nav({ screen: "managed-pages:screen:page-list" })` darauf
13
+ // (cross-feature Nav→Screen ist gegen den globalen Screen-QN-Set validiert).
14
+
15
+ const ADMIN_ROLES = ["TenantAdmin", "SystemAdmin"] as const;
16
+
17
+ export const pageListScreen: EntityListScreenDefinition = {
18
+ id: "page-list",
19
+ type: "entityList",
20
+ entity: "page",
21
+ columns: [
22
+ "slug",
23
+ "lang",
24
+ "title",
25
+ {
26
+ field: "published",
27
+ renderer: { format: "boolean", trueLabel: "Published", falseLabel: "Draft" },
28
+ },
29
+ ],
30
+ defaultSort: { field: "slug", dir: "asc" },
31
+ searchable: true,
32
+ rowActions: [
33
+ {
34
+ kind: "navigate",
35
+ id: "edit",
36
+ label: "managed-pages:actions.edit",
37
+ screen: "page-edit",
38
+ entityId: "id",
39
+ },
40
+ {
41
+ kind: "writeHandler",
42
+ id: "delete",
43
+ label: "managed-pages:actions.delete",
44
+ handler: "managed-pages:write:page:delete",
45
+ payload: { pick: ["id"] },
46
+ confirm: "managed-pages:confirms.page-delete",
47
+ style: "danger",
48
+ },
49
+ ],
50
+ access: { roles: ADMIN_ROLES },
51
+ };
52
+
53
+ export const pageEditScreen: EntityEditScreenDefinition = {
54
+ id: "page-edit",
55
+ type: "entityEdit",
56
+ entity: "page",
57
+ layout: {
58
+ sections: [
59
+ {
60
+ title: "managed-pages:section.meta",
61
+ columns: 2,
62
+ fields: [
63
+ { field: "slug", span: 1 },
64
+ { field: "lang", span: 1 },
65
+ { field: "title", span: 2 },
66
+ { field: "description", span: 2 },
67
+ { field: "ogImage", span: 2 },
68
+ // Publish/Unpublish: der `published`-Toggle hier IST der
69
+ // Publish-Mechanismus (kein separater One-Click-List-Action —
70
+ // rowAction-payload kann keine Konstanten injizieren).
71
+ { field: "published", span: 1 },
72
+ ],
73
+ },
74
+ {
75
+ title: "managed-pages:section.body",
76
+ columns: 1,
77
+ fields: [{ field: "body", span: 1 }],
78
+ },
79
+ ],
80
+ },
81
+ access: { roles: ADMIN_ROLES },
82
+ };