@cosmicdrift/kumiko-bundled-features 0.50.0 → 0.52.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 (46) 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
  41. package/src/subscription-stripe/__tests__/feature.test.ts +3 -2
  42. package/src/subscription-stripe/__tests__/runtime.test.ts +12 -10
  43. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +24 -12
  44. package/src/subscription-stripe/constants.ts +6 -5
  45. package/src/subscription-stripe/feature.ts +69 -50
  46. package/src/subscription-stripe/runtime.ts +29 -14
@@ -0,0 +1,536 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
3
+ import {
4
+ createTestUser,
5
+ setupTestStack,
6
+ type TestStack,
7
+ unsafeCreateEntityTable,
8
+ unsafePushTables,
9
+ } from "@cosmicdrift/kumiko-framework/stack";
10
+ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
11
+ import { createConfigAccessorFactory, createConfigFeature } from "../../config/feature";
12
+ import { createConfigResolver } from "../../config/resolver";
13
+ import { configValuesTable } from "../../config/table";
14
+ import { BRANDING_QN, BRANDING_QUERY_QN } from "../branding";
15
+ import { createManagedPagesCssFeature } from "../css-gate";
16
+ import { createManagedPagesFeature } from "../feature";
17
+ import { seedPage } from "../seeding";
18
+ import { pageEntity } from "../table";
19
+
20
+ const TENANT_A = "11111111-1111-4111-8111-111111111111";
21
+ const TENANT_B = "22222222-2222-4222-8222-222222222222";
22
+
23
+ // Authenticated Admin-Authoring-User. `createTestUser` ohne explizite
24
+ // tenantId teilt den Default-Tenant (nur `id` variiert die User-Id) — für
25
+ // die Cross-Tenant-Isolation braucht `otherAdmin` daher eine EXPLIZITE,
26
+ // distinkte tenantId.
27
+ const tenantAdmin = createTestUser({ id: 10, roles: ["TenantAdmin"] });
28
+ const otherAdmin = createTestUser({
29
+ id: 11,
30
+ roles: ["TenantAdmin"],
31
+ tenantId: "33333333-3333-4333-8333-333333333333",
32
+ });
33
+ const normalUser = createTestUser({ id: 12 });
34
+
35
+ let stack: TestStack;
36
+
37
+ // Host → Tenant: a.* → A, b.* → B, sonst kein Tenant (404). Steht für
38
+ // publicstatus' Subdomain-Auflösung bzw. studios eigene.
39
+ const managed = createManagedPagesFeature({
40
+ resolveApexTenant: (host) => {
41
+ if (host.startsWith("a.")) return TENANT_A;
42
+ if (host.startsWith("b.")) return TENANT_B;
43
+ return null;
44
+ },
45
+ });
46
+
47
+ // managed-pages declares `r.requires("config")` for the branding keys —
48
+ // the config feature must be in the stack and `ctx.config` wired.
49
+ const configFeature = createConfigFeature();
50
+
51
+ beforeAll(async () => {
52
+ // KEIN defaultTenantId — der lockt Single-Tenant-Modus und würde den
53
+ // per-Page-Route gesetzten X-Tenant (≠ default) mit 400 tenant_mismatch
54
+ // ablehnen. Multi-Tenant nutzt den X-Tenant-Header (clientTenant gewinnt),
55
+ // tenantExists validiert ihn. Spiegelt publicstatus (host-basierter
56
+ // tenantResolver, kein fixer default).
57
+ const resolver = createConfigResolver();
58
+ stack = await setupTestStack({
59
+ features: [configFeature, managed],
60
+ anonymousAccess: {
61
+ tenantExists: async (id) => id === TENANT_A || id === TENANT_B,
62
+ },
63
+ // Wire ctx.config() so the branding query resolves the (X-Tenant) tenant's
64
+ // branding cascade. Branding keys are not encrypted → no encryption needed.
65
+ extraContext: ({ registry }) => ({
66
+ configResolver: resolver,
67
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
68
+ }),
69
+ });
70
+ await unsafeCreateEntityTable(stack.db, pageEntity);
71
+ await unsafePushTables(stack.db, { configValuesTable });
72
+ await createEventsTable(stack.db);
73
+
74
+ await seedPage(stack.db, {
75
+ tenantId: TENANT_A,
76
+ slug: "about",
77
+ lang: "en",
78
+ title: "About A",
79
+ body: "# Hello from **A**",
80
+ published: true,
81
+ });
82
+ await seedPage(stack.db, {
83
+ tenantId: TENANT_A,
84
+ slug: "secret",
85
+ lang: "en",
86
+ title: "Secret A",
87
+ body: "draft only",
88
+ published: false,
89
+ });
90
+ await seedPage(stack.db, {
91
+ tenantId: TENANT_B,
92
+ slug: "about",
93
+ lang: "en",
94
+ title: "About B",
95
+ body: "# Hello from **B**",
96
+ published: true,
97
+ });
98
+ });
99
+
100
+ afterAll(async () => {
101
+ await stack.cleanup();
102
+ });
103
+
104
+ describe("managed-pages :: server-render route", () => {
105
+ test("published Page → 200 mit gerendertem Markdown", async () => {
106
+ const res = await stack.app.request("http://a.example.com/p/about");
107
+ expect(res.status).toBe(200);
108
+ expect(res.headers.get("content-type")).toContain("text/html");
109
+ const html = await res.text();
110
+ expect(html).toContain("About A");
111
+ expect(html).toContain("<strong>A</strong>");
112
+ expect(html).toContain('lang="en"');
113
+ });
114
+
115
+ test("Draft (unpublished) → 404 für anonyme Besucher", async () => {
116
+ const res = await stack.app.request("http://a.example.com/p/secret");
117
+ expect(res.status).toBe(404);
118
+ });
119
+
120
+ test("unbekannter Slug → 404", async () => {
121
+ const res = await stack.app.request("http://a.example.com/p/nope");
122
+ expect(res.status).toBe(404);
123
+ });
124
+
125
+ test("Host ohne Tenant (resolveApexTenant null) → 404", async () => {
126
+ const res = await stack.app.request("http://unknown.example.com/p/about");
127
+ expect(res.status).toBe(404);
128
+ });
129
+ });
130
+
131
+ describe("managed-pages :: Cross-Tenant-Isolation", () => {
132
+ test("derselbe Slug serviert pro Host den jeweiligen Tenant-Content", async () => {
133
+ const a = await (await stack.app.request("http://a.example.com/p/about")).text();
134
+ const b = await (await stack.app.request("http://b.example.com/p/about")).text();
135
+ expect(a).toContain("About A");
136
+ expect(a).not.toContain("About B");
137
+ expect(b).toContain("About B");
138
+ expect(b).not.toContain("About A");
139
+ });
140
+ });
141
+
142
+ describe("managed-pages :: Cache + Security-Header", () => {
143
+ test("Vary: Host + CSP/Hardening-Header", async () => {
144
+ const res = await stack.app.request("http://a.example.com/p/about");
145
+ expect(res.headers.get("vary")).toBe("Host");
146
+ expect(res.headers.get("cache-control")).toBe("public, max-age=300");
147
+ expect(res.headers.get("content-security-policy")).toContain("script-src 'none'");
148
+ expect(res.headers.get("x-content-type-options")).toBe("nosniff");
149
+ });
150
+ });
151
+
152
+ describe("managed-pages :: XSS-Härtung", () => {
153
+ test("<script> im Page-Body wird escaped", async () => {
154
+ await seedPage(stack.db, {
155
+ tenantId: TENANT_A,
156
+ slug: "xss",
157
+ lang: "en",
158
+ title: "XSS",
159
+ body: "## Test\n\n<script>window.x=1</script>\n\nok",
160
+ published: true,
161
+ });
162
+ const res = await stack.app.request("http://a.example.com/p/xss");
163
+ expect(res.status).toBe(200);
164
+ const html = await res.text();
165
+ expect(html).not.toContain("<script>window.x=1</script>");
166
+ expect(html).toContain("&lt;script&gt;");
167
+ });
168
+ });
169
+
170
+ describe("managed-pages :: Admin-Screens registriert", () => {
171
+ test("entityList + entityEdit Screens sind im Feature deklariert", () => {
172
+ const ids = Object.keys(managed.screens);
173
+ expect(ids).toContain("page-list");
174
+ expect(ids).toContain("page-edit");
175
+ });
176
+ });
177
+
178
+ describe("managed-pages :: Convention-CRUD (Admin-Authoring)", () => {
179
+ test("create → list → detail → update(publish) → public-read → delete", async () => {
180
+ await stack.http.writeOk(
181
+ "managed-pages:write:page:create",
182
+ { slug: "crud", lang: "en", title: "CRUD", body: "# Body", published: false },
183
+ tenantAdmin,
184
+ );
185
+
186
+ // id robust über die Liste holen (unabhängig von der create-Return-Shape).
187
+ const list = await stack.http.queryOk<{ rows: Array<{ id: string; slug: string }> }>(
188
+ "managed-pages:query:page:list",
189
+ {},
190
+ tenantAdmin,
191
+ );
192
+ const row = list.rows.find((r) => r.slug === "crud");
193
+ expect(row).toBeTruthy();
194
+ const id = row!.id;
195
+
196
+ const detail = await stack.http.queryOk<{ title: string; published: boolean; version: number }>(
197
+ "managed-pages:query:page:detail",
198
+ { id },
199
+ tenantAdmin,
200
+ );
201
+ expect(detail).toMatchObject({ title: "CRUD", published: false });
202
+
203
+ // Draft ist über die Public-Query (published-only) unsichtbar.
204
+ const draftRead = await stack.http.queryOk<unknown>(
205
+ "managed-pages:query:by-slug",
206
+ { slug: "crud", lang: "en" },
207
+ tenantAdmin,
208
+ );
209
+ expect(draftRead).toBeFalsy();
210
+
211
+ // update: publish + Title ändern.
212
+ await stack.http.writeOk(
213
+ "managed-pages:write:page:update",
214
+ { id, version: detail.version, changes: { published: true, title: "CRUD v2" } },
215
+ tenantAdmin,
216
+ );
217
+
218
+ // Public-Query liefert die Page jetzt (published) mit neuem Title.
219
+ const pubRead = await stack.http.queryOk<{ title: string }>(
220
+ "managed-pages:query:by-slug",
221
+ { slug: "crud", lang: "en" },
222
+ tenantAdmin,
223
+ );
224
+ expect(pubRead).toMatchObject({ title: "CRUD v2" });
225
+
226
+ // delete → detail ist weg.
227
+ await stack.http.writeOk("managed-pages:write:page:delete", { id }, tenantAdmin);
228
+ const afterDelete = await stack.http.queryOk<unknown>(
229
+ "managed-pages:query:page:detail",
230
+ { id },
231
+ tenantAdmin,
232
+ );
233
+ expect(afterDelete).toBeFalsy();
234
+ });
235
+
236
+ test("normaler User darf nicht erstellen (access_denied)", async () => {
237
+ const error = await stack.http.writeErr(
238
+ "managed-pages:write:page:create",
239
+ { slug: "denied", lang: "en", title: "x", body: null },
240
+ normalUser,
241
+ );
242
+ expectErrorIncludes(error, "access_denied");
243
+ });
244
+
245
+ test("Cross-Tenant-Isolation: List zeigt nur eigene Pages", async () => {
246
+ await stack.http.writeOk(
247
+ "managed-pages:write:page:create",
248
+ { slug: "tenant-a-only", lang: "en", title: "A only", body: "x", published: false },
249
+ tenantAdmin,
250
+ );
251
+ const otherList = await stack.http.queryOk<{ rows: Array<{ slug: string }> }>(
252
+ "managed-pages:query:page:list",
253
+ {},
254
+ otherAdmin,
255
+ );
256
+ expect(otherList.rows.some((r) => r.slug === "tenant-a-only")).toBe(false);
257
+ });
258
+ });
259
+
260
+ describe("managed-pages :: set (Provisioning-API)", () => {
261
+ test("idempotenter slug-keyed Upsert + preserve-on-omit (published)", async () => {
262
+ const first = await stack.http.writeOk<{ isNew: boolean }>(
263
+ "managed-pages:write:set",
264
+ { slug: "prov", lang: "en", title: "Prov v1", body: "a", published: true },
265
+ tenantAdmin,
266
+ );
267
+ expect(first).toMatchObject({ isNew: true });
268
+
269
+ // Zweiter Call = Update (selber slug+lang); published bei Omit erhalten.
270
+ const second = await stack.http.writeOk<{ isNew: boolean }>(
271
+ "managed-pages:write:set",
272
+ { slug: "prov", lang: "en", title: "Prov v2", body: "b" },
273
+ tenantAdmin,
274
+ );
275
+ expect(second).toMatchObject({ isNew: false });
276
+
277
+ // Beweis: published blieb true (preserve-on-omit) → Public-Query liefert
278
+ // die Page mit aktualisiertem Title.
279
+ const read = await stack.http.queryOk<{ title: string }>(
280
+ "managed-pages:query:by-slug",
281
+ { slug: "prov", lang: "en" },
282
+ tenantAdmin,
283
+ );
284
+ expect(read).toMatchObject({ title: "Prov v2" });
285
+ });
286
+ });
287
+
288
+ describe("managed-pages :: Branding (Config + Render)", () => {
289
+ // config:write:set leitet tenantId aus user.tenantId ab → tenant-spezifische
290
+ // Admins, damit das Branding auf TENANT_A bzw. TENANT_B landet (Host a.*/b.*).
291
+ const adminA = createTestUser({ id: 20, roles: ["TenantAdmin"], tenantId: TENANT_A });
292
+ const adminB = createTestUser({ id: 21, roles: ["TenantAdmin"], tenantId: TENANT_B });
293
+
294
+ test("configEdit-Screen branding-settings ist registriert", () => {
295
+ expect(Object.keys(managed.screens)).toContain("branding-settings");
296
+ });
297
+
298
+ test("Custom-CSS-Key NICHT registriert ohne allowCustomCss (fail-closed-by-construction)", async () => {
299
+ // Default `managed` stack hat allowCustomCss=false → der branding-custom-css
300
+ // Key existiert nicht → ein Write darauf wird abgelehnt. Sperrt die
301
+ // Fail-closed-Eigenschaft gegen eine künftige "Key-immer-registrieren"-Regression.
302
+ const error = await stack.http.writeErr(
303
+ "config:write:set",
304
+ { key: BRANDING_QN.customCss, value: ".x { color: red; }" },
305
+ adminA,
306
+ );
307
+ expect(error).toBeTruthy();
308
+ });
309
+
310
+ test("valides Branding (Hex + https + Preset) wird gesetzt und im Render angewandt", async () => {
311
+ await stack.http.writeOk(
312
+ "config:write:set",
313
+ { key: BRANDING_QN.accentColor, value: "#ff8800" },
314
+ adminA,
315
+ );
316
+ await stack.http.writeOk(
317
+ "config:write:set",
318
+ { key: BRANDING_QN.logoUrl, value: "https://cdn-a.example.com/logo.png" },
319
+ adminA,
320
+ );
321
+ await stack.http.writeOk(
322
+ "config:write:set",
323
+ { key: BRANDING_QN.title, value: "Acme A" },
324
+ adminA,
325
+ );
326
+ await stack.http.writeOk(
327
+ "config:write:set",
328
+ { key: BRANDING_QN.layoutPreset, value: "wide" },
329
+ adminA,
330
+ );
331
+ await stack.http.writeOk(
332
+ "config:write:set",
333
+ { key: BRANDING_QN.description, value: "Acme status and docs" },
334
+ adminA,
335
+ );
336
+
337
+ const html = await (await stack.app.request("http://a.example.com/p/about")).text();
338
+ // scoped :root-Override mit Accent + Preset-max-width
339
+ expect(html).toContain('<style id="tenant-theme">');
340
+ expect(html).toContain("--accent:#ff8800");
341
+ expect(html).toContain("--page-max-width:1100px");
342
+ // Logo + Titel im Branding-Header
343
+ expect(html).toContain('src="https://cdn-a.example.com/logo.png"');
344
+ expect(html).toContain("Acme A");
345
+ // branding-description als Site-Default-Meta (Seite "about" hat keine eigene)
346
+ expect(html).toContain('<meta name="description" content="Acme status and docs">');
347
+ });
348
+
349
+ test("invalide Accent-Farbe (kein Hex, CSS-Injection-Versuch) → Write abgelehnt", async () => {
350
+ const error = await stack.http.writeErr(
351
+ "config:write:set",
352
+ { key: BRANDING_QN.accentColor, value: "red; } body{display:none}" },
353
+ adminA,
354
+ );
355
+ expectErrorIncludes(error, "invalid_format");
356
+ });
357
+
358
+ test("non-https Logo-URL → Write abgelehnt", async () => {
359
+ const error = await stack.http.writeErr(
360
+ "config:write:set",
361
+ { key: BRANDING_QN.logoUrl, value: "http://insecure.example.com/logo.png" },
362
+ adminA,
363
+ );
364
+ expectErrorIncludes(error, "invalid_format");
365
+ });
366
+
367
+ test("leerer Wert (clear) ist erlaubt — Pattern allow-empty", async () => {
368
+ await stack.http.writeOk(
369
+ "config:write:set",
370
+ { key: BRANDING_QN.siteUrl, value: "https://acme.test" },
371
+ adminA,
372
+ );
373
+ await stack.http.writeOk("config:write:set", { key: BRANDING_QN.siteUrl, value: "" }, adminA);
374
+ const branding = await stack.http.queryOk<{ siteUrl: string }>(BRANDING_QUERY_QN, {}, adminA);
375
+ expect(branding.siteUrl).toBe("");
376
+ });
377
+
378
+ test("über-langer Title (>200) → Write abgelehnt (Server-Längen-Cap)", async () => {
379
+ const error = await stack.http.writeErr(
380
+ "config:write:set",
381
+ { key: BRANDING_QN.title, value: "x".repeat(201) },
382
+ adminA,
383
+ );
384
+ expectErrorIncludes(error, "invalid_format");
385
+ });
386
+
387
+ test("Cross-Tenant-Isolation: TENANT_A-Branding leakt nicht auf TENANT_B", async () => {
388
+ await stack.http.writeOk(
389
+ "config:write:set",
390
+ { key: BRANDING_QN.accentColor, value: "#0033cc" },
391
+ adminB,
392
+ );
393
+ const htmlB = await (await stack.app.request("http://b.example.com/p/about")).text();
394
+ expect(htmlB).toContain("--accent:#0033cc");
395
+ expect(htmlB).not.toContain("#ff8800");
396
+ expect(htmlB).not.toContain("cdn-a.example.com");
397
+ expect(htmlB).not.toContain("Acme A");
398
+ });
399
+ });
400
+
401
+ // Eigener Stack mit allowCustomCss:true + dem Companion-Toggle-Feature. Der
402
+ // per-Tenant-Gate wird über `effectiveFeatures` simuliert: TENANT_A hat
403
+ // `managed-pages-css` AN, TENANT_B AUS — gleiche App-Opt-in (allowCustomCss),
404
+ // unterschiedlicher Operator-/Tier-Toggle. Beweist End-to-End-Render + den
405
+ // Kill-Switch + dass der Render-Sanitizer Write-Bypass-Werte abfängt.
406
+ describe("managed-pages :: Custom CSS (gated, sanitized render)", () => {
407
+ let cssStack: TestStack;
408
+ const cssAdminA = createTestUser({ id: 30, roles: ["TenantAdmin"], tenantId: TENANT_A });
409
+ const cssAdminB = createTestUser({ id: 31, roles: ["TenantAdmin"], tenantId: TENANT_B });
410
+
411
+ const managedWithCss = createManagedPagesFeature({
412
+ resolveApexTenant: (host) => {
413
+ if (host.startsWith("a.")) return TENANT_A;
414
+ if (host.startsWith("b.")) return TENANT_B;
415
+ return null;
416
+ },
417
+ allowCustomCss: true,
418
+ });
419
+ const cssGate = createManagedPagesCssFeature();
420
+ const cssConfigFeature = createConfigFeature();
421
+
422
+ beforeAll(async () => {
423
+ const resolver = createConfigResolver();
424
+ cssStack = await setupTestStack({
425
+ features: [cssConfigFeature, managedWithCss, cssGate],
426
+ anonymousAccess: {
427
+ tenantExists: async (id) => id === TENANT_A || id === TENANT_B,
428
+ },
429
+ // Per-Tenant-Toggle: A enabled, B disabled.
430
+ effectiveFeatures: (tid) =>
431
+ tid === TENANT_A
432
+ ? new Set(["config", "managed-pages", "managed-pages-css"])
433
+ : new Set(["config", "managed-pages"]),
434
+ extraContext: ({ registry }) => ({
435
+ configResolver: resolver,
436
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
437
+ }),
438
+ });
439
+ await unsafeCreateEntityTable(cssStack.db, pageEntity);
440
+ await unsafePushTables(cssStack.db, { configValuesTable });
441
+ await createEventsTable(cssStack.db);
442
+ await seedPage(cssStack.db, {
443
+ tenantId: TENANT_A,
444
+ slug: "about",
445
+ lang: "en",
446
+ title: "About A",
447
+ body: "# A",
448
+ published: true,
449
+ });
450
+ await seedPage(cssStack.db, {
451
+ tenantId: TENANT_B,
452
+ slug: "about",
453
+ lang: "en",
454
+ title: "About B",
455
+ body: "# B",
456
+ published: true,
457
+ });
458
+ });
459
+
460
+ afterAll(async () => {
461
+ await cssStack.cleanup();
462
+ });
463
+
464
+ test("custom-css Config-Key ist registriert wenn allowCustomCss (Write ok)", async () => {
465
+ await cssStack.http.writeOk(
466
+ "config:write:set",
467
+ { key: BRANDING_QN.customCss, value: ".note { color: red; }" },
468
+ cssAdminA,
469
+ );
470
+ // Gate AN → die branding-Query gibt den (rohen) customCss zurück; beweist
471
+ // dass der Key registriert ist UND der Wert persistiert (nicht nur „kein
472
+ // Write-Fehler").
473
+ const branding = await cssStack.http.queryOk<{ customCss?: string }>(
474
+ BRANDING_QUERY_QN,
475
+ {},
476
+ cssAdminA,
477
+ );
478
+ expect(branding.customCss).toContain(".note");
479
+ });
480
+
481
+ test("Gate AN: Tenant-CSS gescoped in <style data-tenant-css> gerendert", async () => {
482
+ await cssStack.http.writeOk(
483
+ "config:write:set",
484
+ { key: BRANDING_QN.customCss, value: ".note { color: rebeccapurple; }" },
485
+ cssAdminA,
486
+ );
487
+ const html = await (await cssStack.app.request("http://a.example.com/p/about")).text();
488
+ expect(html).toContain("<style data-tenant-css>");
489
+ expect(html).toContain("[data-tenant-content] .note");
490
+ expect(html).toContain("color: rebeccapurple");
491
+ expect(html).toContain("<main data-tenant-content>");
492
+ // full containment (position/isolation + overflow clip) ships in the
493
+ // tenant-css block, only alongside tenant CSS — boxes + clips tenant paint
494
+ // off host chrome.
495
+ expect(html).toContain("[data-tenant-content]{position:relative;isolation:isolate}");
496
+ expect(html).toContain("[data-tenant-content]{overflow:hidden}");
497
+ });
498
+
499
+ test("gespeichertes Angriffs-CSS wird am Render sanitized (Write-Gate-Bypass-Abwehr)", async () => {
500
+ // Der Längen-Cap-Pattern lässt das speichern; der Render-Sanitizer ist der
501
+ // eigentliche Allowlist-Gate. Jeder Vektor einzeln exerziert.
502
+ const attack =
503
+ ".evil { position: fixed; top: 0; } .ok { color: red; } @import url('http://evil.test');";
504
+ await cssStack.http.writeOk(
505
+ "config:write:set",
506
+ { key: BRANDING_QN.customCss, value: attack },
507
+ cssAdminA,
508
+ );
509
+ const html = await (await cssStack.app.request("http://a.example.com/p/about")).text();
510
+ expect(html).not.toContain("position: fixed");
511
+ expect(html).not.toContain("@import");
512
+ expect(html).not.toContain("evil.test");
513
+ // die eine sichere Regel überlebt, gescoped
514
+ expect(html).toContain("[data-tenant-content] .ok");
515
+ expect(html).toContain("color: red");
516
+ });
517
+
518
+ test("Gate AUS (Toggle off für Tenant B): kein Tenant-CSS trotz gespeichertem Wert", async () => {
519
+ await cssStack.http.writeOk(
520
+ "config:write:set",
521
+ { key: BRANDING_QN.customCss, value: ".note { color: red; }" },
522
+ cssAdminB,
523
+ );
524
+ const html = await (await cssStack.app.request("http://b.example.com/p/about")).text();
525
+ expect(html).not.toContain("<style data-tenant-css>");
526
+ expect(html).not.toContain("[data-tenant-content] .note");
527
+ // no tenant CSS → no clip (plain pages keep normal overflow for wide content)
528
+ expect(html).not.toContain("overflow:hidden");
529
+ });
530
+
531
+ test("configEdit-Screen zeigt das customCss-Feld wenn allowCustomCss", () => {
532
+ const screen = managedWithCss.screens["branding-settings"];
533
+ expect(screen).toBeTruthy();
534
+ expect(JSON.stringify(screen)).toContain("branding-custom-css");
535
+ });
536
+ });
@@ -0,0 +1,142 @@
1
+ import {
2
+ type ConfigAccessor,
3
+ type ConfigKeyDefinition,
4
+ createTenantConfig,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { type BrandingTokens, EMPTY_BRANDING } from "../page-render";
7
+
8
+ // Per-tenant branding, lifted from the publicstatus-local branding-config
9
+ // pattern into the framework so every app + studio-tenant gets tenant-editable
10
+ // branding. Stored as `config` keys (scope: tenant), edited via the
11
+ // configEdit screen (screens/branding-screen.ts), read at render time via the
12
+ // branding query.
13
+ //
14
+ // Write-validated: accent-color must be a CSS hex, logo/site URLs must be
15
+ // https. The configEdit screen dispatches `config:write:set` per key, which
16
+ // runs the keyDef.pattern gate (set.write.ts → validatePattern). `read: all`
17
+ // (scope default) so the anonymous public-render path can read them;
18
+ // `write: admin` (TenantAdmin/Admin/SystemAdmin, also the scope default).
19
+ //
20
+ // CONTINUITY (Phase 5 — Prod trap): these keys land under
21
+ // `managed-pages:config:branding-*`, NOT the live `publicstatus:config:
22
+ // branding-*`. The publicstatus consumer (Phase 5) MUST migrate the existing
23
+ // read_config_values rows to the new QNs per tenant BEFORE switching the read
24
+ // path, or every tenant resets to defaults on deploy. The migration is
25
+ // deliberately NOT built here — the source QNs belong to the consumer app.
26
+
27
+ // Anchored, allow-empty (empty = "unset, use default"; cleared via the form).
28
+ // Linear → ReDoS-safe on untrusted tenant input. The URL char-class mirrors
29
+ // the render-side validator (page-render/branding.ts) so a value that saves
30
+ // also renders. text keys carry an explicit length cap too — `validateType`
31
+ // only checks the JS type, and the configEdit `maxLength` is client-side, so a
32
+ // direct config:write:set would otherwise be unbounded (the page body is
33
+ // likewise capped in Phase 2).
34
+ const HEX_PATTERN = {
35
+ regex: "^$|^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
36
+ } as const;
37
+ const HTTPS_PATTERN = { regex: "^$|^https://[^\\s\"'<>]{1,2000}$" } as const;
38
+ const TITLE_PATTERN = { regex: "^[\\s\\S]{0,200}$" } as const;
39
+ const DESCRIPTION_PATTERN = { regex: "^[\\s\\S]{0,500}$" } as const;
40
+ // Custom CSS is too complex for a regex allowlist — the write-time pattern only
41
+ // caps length (ReDoS-safe, allow-empty); the real allowlist sanitization runs at
42
+ // render (page-render/css-sanitize.ts). Keep this cap in sync with MAX_CSS_LENGTH
43
+ // there.
44
+ const CUSTOM_CSS_PATTERN = { regex: "^[\\s\\S]{0,8000}$" } as const;
45
+
46
+ export const LAYOUT_PRESETS = ["minimal", "centered", "wide"] as const;
47
+
48
+ // Companion toggle feature name. managed-pages reads ctx.hasFeature() against it
49
+ // in the branding query to per-tenant-gate custom-CSS emission. Declared as a
50
+ // `r.toggleable({default:false})` feature in css-gate.ts.
51
+ export const MANAGED_PAGES_CSS_FEATURE = "managed-pages-css";
52
+
53
+ // Short keys → qualified names via define-feature's `qn(feature, "config",
54
+ // toKebab(key))`: e.g. `brandingSiteUrl` → `managed-pages:config:branding-
55
+ // site-url`. BRANDING_QN below is the single source those QN strings are read
56
+ // from (configEdit screen + render read); the integration test pins write-
57
+ // target == read-source end-to-end.
58
+ export const BRANDING_KEYS = {
59
+ brandingTitle: createTenantConfig("text", { default: "", pattern: TITLE_PATTERN }),
60
+ brandingDescription: createTenantConfig("text", { default: "", pattern: DESCRIPTION_PATTERN }),
61
+ brandingSiteUrl: createTenantConfig("text", { default: "", pattern: HTTPS_PATTERN }),
62
+ brandingAccentColor: createTenantConfig("text", { default: "", pattern: HEX_PATTERN }),
63
+ brandingLogoUrl: createTenantConfig("text", { default: "", pattern: HTTPS_PATTERN }),
64
+ brandingLayoutPreset: createTenantConfig("select", {
65
+ default: "centered",
66
+ options: LAYOUT_PRESETS,
67
+ }),
68
+ } satisfies Record<string, ConfigKeyDefinition>;
69
+
70
+ // Registered ONLY when the app passes allowCustomCss:true — kept out of
71
+ // BRANDING_KEYS so the CSS editor field + key don't exist when the capability is
72
+ // off (fail-closed at the app level). The render-time sanitizer is the real
73
+ // safety boundary; this is the opt-in switch + a per-tenant toggle gate.
74
+ export const CUSTOM_CSS_KEY = {
75
+ brandingCustomCss: createTenantConfig("text", { default: "", pattern: CUSTOM_CSS_PATTERN }),
76
+ } satisfies Record<string, ConfigKeyDefinition>;
77
+
78
+ export const BRANDING_QN = {
79
+ title: "managed-pages:config:branding-title",
80
+ description: "managed-pages:config:branding-description",
81
+ siteUrl: "managed-pages:config:branding-site-url",
82
+ accentColor: "managed-pages:config:branding-accent-color",
83
+ logoUrl: "managed-pages:config:branding-logo-url",
84
+ layoutPreset: "managed-pages:config:branding-layout-preset",
85
+ customCss: "managed-pages:config:branding-custom-css",
86
+ } as const;
87
+
88
+ export const BRANDING_QUERY_QN = "managed-pages:query:branding";
89
+
90
+ async function readText(config: ConfigAccessor, qualifiedKey: string): Promise<string> {
91
+ const value = await config(qualifiedKey);
92
+ return typeof value === "string" ? value : "";
93
+ }
94
+
95
+ // Resolve the tenant's branding cascade into render tokens. `config` is
96
+ // optional because ctx.config is only wired when the app composes the config
97
+ // feature — a missing accessor degrades to defaults (same posture as
98
+ // publicstatus' branding read), not a crash.
99
+ export async function readBranding(config: ConfigAccessor | undefined): Promise<BrandingTokens> {
100
+ if (!config) return EMPTY_BRANDING;
101
+ const [title, description, siteUrl, accentColor, logoUrl, layoutPreset] = await Promise.all([
102
+ readText(config, BRANDING_QN.title),
103
+ readText(config, BRANDING_QN.description),
104
+ readText(config, BRANDING_QN.siteUrl),
105
+ readText(config, BRANDING_QN.accentColor),
106
+ readText(config, BRANDING_QN.logoUrl),
107
+ readText(config, BRANDING_QN.layoutPreset),
108
+ ]);
109
+ // customCss stays "" here — it is gated (allowCustomCss + per-tenant toggle)
110
+ // and read separately by the branding query via readCustomCss.
111
+ return { title, description, siteUrl, accentColor, logoUrl, layoutPreset, customCss: "" };
112
+ }
113
+
114
+ // Read the raw, untrusted custom CSS for the request's tenant. The caller (the
115
+ // branding query) only invokes this once the gate has passed; the value is
116
+ // sanitized at render (page-render), never trusted here.
117
+ export async function readCustomCss(config: ConfigAccessor): Promise<string> {
118
+ return readText(config, BRANDING_QN.customCss);
119
+ }
120
+
121
+ function stringField(source: Record<string, unknown>, key: string): string {
122
+ const value = source[key];
123
+ return typeof value === "string" ? value : "";
124
+ }
125
+
126
+ // Coerce the branding query's wire response (`{ data: <BrandingTokens> }`)
127
+ // into BrandingTokens at the IO boundary, without an `as` cast — any missing/
128
+ // non-string field falls back to "" so a malformed/empty response renders the
129
+ // unbranded default rather than throwing.
130
+ export function coerceBranding(value: unknown): BrandingTokens {
131
+ if (typeof value !== "object" || value === null) return EMPTY_BRANDING;
132
+ const source = Object.fromEntries(Object.entries(value));
133
+ return {
134
+ title: stringField(source, "title"),
135
+ description: stringField(source, "description"),
136
+ siteUrl: stringField(source, "siteUrl"),
137
+ accentColor: stringField(source, "accentColor"),
138
+ logoUrl: stringField(source, "logoUrl"),
139
+ layoutPreset: stringField(source, "layoutPreset"),
140
+ customCss: stringField(source, "customCss"),
141
+ };
142
+ }