@cosmicdrift/kumiko-bundled-features 0.174.0 → 0.176.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 (80) hide show
  1. package/package.json +8 -10
  2. package/src/__tests__/boot-seed-contract.integration.test.ts +18 -14
  3. package/src/auth-email-password/__tests__/login-gates.test.ts +12 -6
  4. package/src/auth-email-password/seeding.ts +4 -2
  5. package/src/auth-mfa/__tests__/token-secrets.test.ts +19 -0
  6. package/src/auth-mfa/handlers/reencrypt.job.ts +11 -20
  7. package/src/auth-mfa/token-secrets.ts +16 -14
  8. package/src/billing-foundation/subscription-tier-sync.ts +8 -1
  9. package/src/cap-counter/__tests__/stock-cap-guard.integration.test.ts +98 -0
  10. package/src/cap-counter/stock-cap-guard.ts +1 -1
  11. package/src/config/handlers/reencrypt.job.ts +7 -18
  12. package/src/delivery/delivery-service.ts +1 -1
  13. package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +47 -11
  14. package/src/document-ingest-foundation/__tests__/feature.test.ts +6 -2
  15. package/src/document-ingest-foundation/events.ts +15 -0
  16. package/src/document-ingest-foundation/feature.ts +27 -2
  17. package/src/inbound-mail-foundation/watch-supervisor.ts +11 -2
  18. package/src/jobs/handlers/catalog.query.ts +1 -4
  19. package/src/jobs/handlers/trigger.write.ts +2 -1
  20. package/src/jobs/is-manual-trigger.ts +5 -0
  21. package/src/legal-pages/README.md +26 -25
  22. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +93 -61
  23. package/src/legal-pages/feature.ts +40 -31
  24. package/src/legal-pages/web/__tests__/client-plugin.test.ts +2 -2
  25. package/src/legal-pages/web/client-plugin.ts +3 -3
  26. package/src/managed-pages/screens/branding-screen.ts +1 -1
  27. package/src/presets/dsgvo-self-service.ts +10 -10
  28. package/src/seo/__tests__/robots-txt.test.ts +21 -0
  29. package/src/seo/__tests__/seo.integration.test.ts +13 -13
  30. package/src/seo/robots-txt.ts +21 -1
  31. package/src/sessions/handlers/revoke-all-for-user.write.ts +10 -1
  32. package/src/shared/classify-stored-envelope.ts +21 -0
  33. package/src/shared/index.ts +4 -0
  34. package/src/template-resolver/README.md +30 -1
  35. package/src/{text-content → template-resolver}/__tests__/seed-legal-content.integration.test.ts +32 -21
  36. package/src/template-resolver/__tests__/text-blocks.integration.test.ts +563 -0
  37. package/src/template-resolver/api.ts +11 -7
  38. package/src/template-resolver/constants.ts +22 -0
  39. package/src/template-resolver/feature.ts +16 -1
  40. package/src/template-resolver/handlers/by-slug.query.ts +54 -0
  41. package/src/template-resolver/handlers/by-tenant.query.ts +57 -0
  42. package/src/template-resolver/handlers/set.write.ts +110 -0
  43. package/src/template-resolver/handlers/shared.ts +13 -2
  44. package/src/template-resolver/handlers/upsert-system.write.ts +2 -0
  45. package/src/template-resolver/handlers/upsert-tenant.write.ts +2 -0
  46. package/src/template-resolver/index.ts +7 -0
  47. package/src/template-resolver/qualified-names.ts +3 -0
  48. package/src/template-resolver/seeding.ts +126 -2
  49. package/src/template-resolver/table.ts +11 -2
  50. package/src/{text-content → template-resolver}/web/__tests__/client-plugin.test.tsx +26 -19
  51. package/src/{text-content → template-resolver}/web/__tests__/editor-read-only.test.tsx +10 -10
  52. package/src/{text-content → template-resolver}/web/__tests__/group-blocks.test.ts +34 -34
  53. package/src/template-resolver/web/client-plugin.tsx +352 -0
  54. package/src/template-resolver/web/index.ts +8 -0
  55. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +9 -0
  56. package/src/tenant-settings/tenant-defaults.ts +3 -0
  57. package/src/tier-engine/compose-app.ts +2 -13
  58. package/src/user/__tests__/seed-testing.integration.test.ts +25 -0
  59. package/src/user/seeding.ts +25 -1
  60. package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +1 -1
  61. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +2 -2
  62. package/src/user-data-rights/__tests__/request-deletion-url.test.ts +11 -8
  63. package/src/user-data-rights/feature.ts +3 -2
  64. package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +8 -6
  65. package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +5 -5
  66. package/src/user-data-rights/web/confirm-deletion-screen.tsx +3 -2
  67. package/src/text-content/README.md +0 -197
  68. package/src/text-content/__tests__/text-content.integration.test.ts +0 -503
  69. package/src/text-content/api.ts +0 -86
  70. package/src/text-content/changes.json +0 -14
  71. package/src/text-content/constants.ts +0 -21
  72. package/src/text-content/feature.ts +0 -53
  73. package/src/text-content/handlers/by-slug.query.ts +0 -55
  74. package/src/text-content/handlers/by-tenant.query.ts +0 -60
  75. package/src/text-content/handlers/set.write.ts +0 -141
  76. package/src/text-content/index.ts +0 -14
  77. package/src/text-content/seeding.ts +0 -139
  78. package/src/text-content/table.ts +0 -53
  79. package/src/text-content/web/client-plugin.tsx +0 -391
  80. package/src/text-content/web/index.ts +0 -8
@@ -1,503 +0,0 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { type DbConnection, fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
3
- import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
4
- import {
5
- createTestUser,
6
- setupTestStack,
7
- type TestStack,
8
- TestUsers,
9
- unsafeCreateEntityTable,
10
- } from "@cosmicdrift/kumiko-framework/stack";
11
- import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
12
- import { TextContentHandlers, TextContentQueries } from "../constants";
13
- import { createTextContentFeature } from "../feature";
14
- import { seedTextBlock } from "../seeding";
15
- import { type TextBlockRow, textBlockEntity, textBlocksTable } from "../table";
16
-
17
- let stack: TestStack;
18
- let db: DbConnection;
19
-
20
- const systemAdmin = TestUsers.systemAdmin;
21
- const tenantAdmin = createTestUser({ id: 2, roles: ["TenantAdmin"] });
22
- const normalUser = createTestUser({ id: 3 });
23
-
24
- const feature = createTextContentFeature();
25
-
26
- beforeAll(async () => {
27
- stack = await setupTestStack({ features: [feature] });
28
- db = stack.db;
29
- await unsafeCreateEntityTable(db, textBlockEntity);
30
- await createEventsTable(db);
31
- });
32
-
33
- afterAll(async () => {
34
- await stack.cleanup();
35
- });
36
-
37
- describe("text-content :: write", () => {
38
- test("TenantAdmin can create a text block", async () => {
39
- const result = await stack.http.writeOk<Record<string, unknown>>(
40
- TextContentHandlers.set,
41
- {
42
- slug: "imprint",
43
- lang: "de",
44
- title: "Impressum",
45
- body: "## Angaben gemäß § 5 TMG\n\nMarc Frost",
46
- },
47
- tenantAdmin,
48
- );
49
- expect(result).toMatchObject({ slug: "imprint", lang: "de", isNew: true });
50
- });
51
-
52
- test("set is idempotent — second call updates existing block", async () => {
53
- await stack.http.writeOk(
54
- TextContentHandlers.set,
55
- { slug: "privacy", lang: "de", title: "Datenschutz v1", body: "alt" },
56
- tenantAdmin,
57
- );
58
- const result = await stack.http.writeOk<Record<string, unknown>>(
59
- TextContentHandlers.set,
60
- { slug: "privacy", lang: "de", title: "Datenschutz v2", body: "neu" },
61
- tenantAdmin,
62
- );
63
- expect(result).toMatchObject({ slug: "privacy", isNew: false });
64
-
65
- const fetched = await stack.http.queryOk<Record<string, unknown>>(
66
- TextContentQueries.bySlug,
67
- { slug: "privacy", lang: "de" },
68
- tenantAdmin,
69
- );
70
- expect(fetched).toMatchObject({ title: "Datenschutz v2", body: "neu" });
71
- });
72
-
73
- test("SystemAdmin can create text blocks for SYSTEM_TENANT (without TenantAdmin role)", async () => {
74
- // SystemAdmin ist global, hat KEIN implicit TenantAdmin auf seiner
75
- // membership. Das Set-Handler-ACL muss SystemAdmin explizit erlauben
76
- // sonst kann niemand Plattform-Texte (z.B. Impressum) setzen.
77
- const result = await stack.http.writeOk<Record<string, unknown>>(
78
- TextContentHandlers.set,
79
- {
80
- slug: "system-imprint-write",
81
- lang: "de",
82
- title: "System-Impressum",
83
- body: "## Plattform\n\nMarc",
84
- },
85
- systemAdmin,
86
- );
87
- expect(result).toMatchObject({ slug: "system-imprint-write", isNew: true });
88
- });
89
-
90
- test("normal User cannot create text blocks (access denied)", async () => {
91
- const error = await stack.http.writeErr(
92
- TextContentHandlers.set,
93
- { slug: "about", lang: "de", title: "Über", body: null },
94
- normalUser,
95
- );
96
- expectErrorIncludes(error, "access_denied");
97
- });
98
-
99
- test("invalid slug rejected by schema validation", async () => {
100
- const error = await stack.http.writeErr(
101
- TextContentHandlers.set,
102
- { slug: "Invalid Slug!", lang: "de", title: "x", body: null },
103
- tenantAdmin,
104
- );
105
- expectErrorIncludes(error, "validation_error");
106
- });
107
-
108
- test("SystemAdmin can write with tenantIdOverride to a different tenant (legal-pages use-case)", async () => {
109
- // Use-case: Plattform-App's Edit-UI lädt SystemAdmin der NICHT
110
- // member auf SYSTEM_TENANT ist + lässt ihn dort schreiben.
111
- // Ohne override würde der text auf systemAdmin.tenantId landen
112
- // statt SYSTEM_TENANT — legal-pages-routes lesen ihn dann nie.
113
- const targetTenant = createTestUser({ id: 99 }).tenantId;
114
- const result = await stack.http.writeOk<Record<string, unknown>>(
115
- TextContentHandlers.set,
116
- {
117
- slug: "override-target",
118
- lang: "de",
119
- title: "Override-Test",
120
- body: "via tenantIdOverride",
121
- tenantIdOverride: targetTenant,
122
- },
123
- systemAdmin,
124
- );
125
- expect(result).toMatchObject({ slug: "override-target", isNew: true });
126
-
127
- // Beweis: text landed auf TARGET-tenant, nicht auf systemAdmin's
128
- // eigenem tenant. Read mit denselben override returnt den block.
129
- const read = await stack.http.queryOk<Record<string, unknown>>(
130
- TextContentQueries.bySlug,
131
- { slug: "override-target", lang: "de", tenantIdOverride: targetTenant },
132
- systemAdmin,
133
- );
134
- expect(read).toMatchObject({ slug: "override-target", title: "Override-Test" });
135
- });
136
-
137
- test("SystemAdmin can UPDATE with tenantIdOverride (regression: stream-lookup must use override-tenantId, not user.tenantId)", async () => {
138
- // Regression-Guard für 2026-05-04: bei tenantIdOverride MUSS auch der
139
- // user-context für den event-store-executor remapped werden — sonst
140
- // landet append() auf user.tenantId aber getStreamVersion (auf
141
- // update) sucht ebenfalls auf user.tenantId, findet aber NUR den
142
- // stream auf override-tenantId aus dem ersten write → version_conflict
143
- // obwohl die projection-row da ist. Test der NUR create+override
144
- // hatte den Bug nicht gefangen weil append=create ohne stream-lookup.
145
- const targetTenant = createTestUser({ id: 77 }).tenantId;
146
-
147
- // Schritt 1: create mit override.
148
- await stack.http.writeOk<Record<string, unknown>>(
149
- TextContentHandlers.set,
150
- {
151
- slug: "update-target",
152
- lang: "de",
153
- title: "v1",
154
- body: "first",
155
- tenantIdOverride: targetTenant,
156
- },
157
- systemAdmin,
158
- );
159
-
160
- // Schritt 2: UPDATE mit override (selbe slug+lang+target). Vor dem
161
- // Fix: version_conflict. Nach dem Fix: clean update.
162
- const result = await stack.http.writeOk<Record<string, unknown>>(
163
- TextContentHandlers.set,
164
- {
165
- slug: "update-target",
166
- lang: "de",
167
- title: "v2",
168
- body: "updated",
169
- tenantIdOverride: targetTenant,
170
- },
171
- systemAdmin,
172
- );
173
- expect(result).toMatchObject({ slug: "update-target", isNew: false });
174
-
175
- // Beweis: read returnt den UPDATED content auf TARGET-tenant.
176
- const read = await stack.http.queryOk<Record<string, unknown>>(
177
- TextContentQueries.bySlug,
178
- { slug: "update-target", lang: "de", tenantIdOverride: targetTenant },
179
- systemAdmin,
180
- );
181
- expect(read).toMatchObject({ slug: "update-target", title: "v2", body: "updated" });
182
- });
183
-
184
- test("TenantAdmin's tenantIdOverride attempt → 403 access_denied", async () => {
185
- // Defense-in-Depth: override ist SystemAdmin-only. TenantAdmin
186
- // darf NICHT auf andere tenants schreiben — sonst könnte ein
187
- // Tenant-Admin von Tenant-A einfach Tenant-B's Impressum überschreiben.
188
- const otherTenant = createTestUser({ id: 88 }).tenantId;
189
- const error = await stack.http.writeErr(
190
- TextContentHandlers.set,
191
- {
192
- slug: "evil-override",
193
- lang: "de",
194
- title: "evil",
195
- body: null,
196
- tenantIdOverride: otherTenant,
197
- },
198
- tenantAdmin,
199
- );
200
- expectErrorIncludes(error, "access_denied");
201
- });
202
-
203
- test("invalid lang rejected by schema validation", async () => {
204
- const error = await stack.http.writeErr(
205
- TextContentHandlers.set,
206
- { slug: "ok", lang: "DEUTSCH", title: "x", body: null },
207
- tenantAdmin,
208
- );
209
- expectErrorIncludes(error, "validation_error");
210
- });
211
- });
212
-
213
- describe("text-content :: query (openToAll)", () => {
214
- test("by-slug returns existing block for matching tenant/lang", async () => {
215
- await seedTextBlock(db, {
216
- tenantId: tenantAdmin.tenantId,
217
- slug: "about",
218
- lang: "de",
219
- title: "Über uns",
220
- body: "Wir sind ein Team.",
221
- });
222
- const result = await stack.http.queryOk<Record<string, unknown>>(
223
- TextContentQueries.bySlug,
224
- { slug: "about", lang: "de" },
225
- tenantAdmin,
226
- );
227
- expect(result).toMatchObject({
228
- slug: "about",
229
- lang: "de",
230
- title: "Über uns",
231
- body: "Wir sind ein Team.",
232
- });
233
- });
234
-
235
- test("by-slug returns null for missing block", async () => {
236
- const result = await stack.http.queryOk<Record<string, unknown> | null>(
237
- TextContentQueries.bySlug,
238
- { slug: "does-not-exist", lang: "de" },
239
- tenantAdmin,
240
- );
241
- expect(result).toBeFalsy();
242
- });
243
-
244
- test("by-slug isolates by tenant — other tenant's block invisible", async () => {
245
- const otherTenant = createTestUser({
246
- id: 99,
247
- tenantId: "11111111-1111-4111-8111-111111111111",
248
- roles: ["TenantAdmin"],
249
- });
250
- await seedTextBlock(db, {
251
- tenantId: tenantAdmin.tenantId,
252
- slug: "tenant-only",
253
- lang: "de",
254
- title: "Tenant-A only",
255
- });
256
- const result = await stack.http.queryOk<Record<string, unknown> | null>(
257
- TextContentQueries.bySlug,
258
- { slug: "tenant-only", lang: "de" },
259
- otherTenant,
260
- );
261
- expect(result).toBeNull();
262
- });
263
-
264
- test("by-slug works for SystemAdmin scoped to system tenant", async () => {
265
- await seedTextBlock(db, {
266
- tenantId: systemAdmin.tenantId,
267
- slug: "system-imprint",
268
- lang: "de",
269
- title: "System-Impressum",
270
- body: "Plattform-Betreiber",
271
- });
272
- const result = await stack.http.queryOk<Record<string, unknown>>(
273
- TextContentQueries.bySlug,
274
- { slug: "system-imprint", lang: "de" },
275
- systemAdmin,
276
- );
277
- expect(result).toMatchObject({ title: "System-Impressum" });
278
- });
279
- });
280
-
281
- describe("text-content :: edge-cases", () => {
282
- test("body=null roundtrip — set + query liefert null body zurück", async () => {
283
- // Sinnvoller Use-Case: Tenant-Admin legt einen leeren Block als
284
- // Stub an (z.B. während Onboarding) und befüllt ihn später.
285
- await stack.http.writeOk<Record<string, unknown>>(
286
- TextContentHandlers.set,
287
- { slug: "stub-page", lang: "de", title: "Wird noch gefüllt", body: null },
288
- tenantAdmin,
289
- );
290
- const fetched = await stack.http.queryOk<Record<string, unknown>>(
291
- TextContentQueries.bySlug,
292
- { slug: "stub-page", lang: "de" },
293
- tenantAdmin,
294
- );
295
- expect(fetched).toMatchObject({ title: "Wird noch gefüllt", body: null });
296
- });
297
-
298
- test("body=null kann via update auf string gesetzt werden", async () => {
299
- await stack.http.writeOk(
300
- TextContentHandlers.set,
301
- { slug: "later-filled", lang: "de", title: "Stub", body: null },
302
- tenantAdmin,
303
- );
304
- await stack.http.writeOk(
305
- TextContentHandlers.set,
306
- { slug: "later-filled", lang: "de", title: "Stub", body: "Inhalt" },
307
- tenantAdmin,
308
- );
309
- const fetched = await stack.http.queryOk<Record<string, unknown>>(
310
- TextContentQueries.bySlug,
311
- { slug: "later-filled", lang: "de" },
312
- tenantAdmin,
313
- );
314
- expect(fetched!["body"]).toBe("Inhalt");
315
- });
316
-
317
- test("body knapp unter max-length (100k Zeichen) wird akzeptiert", async () => {
318
- const justBelowMax = "a".repeat(100_000);
319
- const result = await stack.http.writeOk<Record<string, unknown>>(
320
- TextContentHandlers.set,
321
- { slug: "max-length-ok", lang: "de", title: "Max", body: justBelowMax },
322
- tenantAdmin,
323
- );
324
- expect(result).toMatchObject({ slug: "max-length-ok", isNew: true });
325
- });
326
-
327
- test("body über max-length (100k+1 Zeichen) → validation_error", async () => {
328
- const overLimit = "a".repeat(100_001);
329
- const error = await stack.http.writeErr(
330
- TextContentHandlers.set,
331
- { slug: "max-length-fail", lang: "de", title: "Over", body: overLimit },
332
- tenantAdmin,
333
- );
334
- expectErrorIncludes(error, "validation_error");
335
- });
336
-
337
- test("body mit XSS-Payload wird unverändert gespeichert (Markdown-Renderer ist verantwortlich für Escaping)", async () => {
338
- // Dokumentiertes Verhalten: text-content speichert Markdown 1:1.
339
- // Konsumenten (z.B. legal-pages mit `marked`) müssen entscheiden ob
340
- // sie sanitizen — siehe legal-pages/README.md XSS-Sektion.
341
- const xssPayload = "## Title\n\n<script>alert('xss')</script>\n\nText.";
342
- await stack.http.writeOk(
343
- TextContentHandlers.set,
344
- { slug: "xss-test", lang: "de", title: "XSS", body: xssPayload },
345
- tenantAdmin,
346
- );
347
- const fetched = await stack.http.queryOk<Record<string, unknown>>(
348
- TextContentQueries.bySlug,
349
- { slug: "xss-test", lang: "de" },
350
- tenantAdmin,
351
- );
352
- // Roundtrip: Body bleibt exakt was reingeschrieben wurde
353
- expect(fetched!["body"]).toBe(xssPayload);
354
- });
355
-
356
- test("concurrent set auf gleichen (tenantId, slug, lang) — mindestens einer succeed", async () => {
357
- // Race-Test: Zwei TenantAdmins (oder selber Admin von zwei Tabs)
358
- // setzen gleichzeitig. fetchOne+update ist nicht atomar — wenn
359
- // beide das selbe `existing` finden und beide updaten wollen,
360
- // greift Optimistic-Locking via version-check im Executor.
361
- // Erwartung: einer succeed, einer kann version_conflict werfen
362
- // (oder beide succeed wenn sequenziell genug). Mindestens einer
363
- // muss durchlaufen, sonst ist der Race-Pfad kaputt.
364
- await stack.http.writeOk(
365
- TextContentHandlers.set,
366
- { slug: "race-test", lang: "de", title: "Initial", body: "v1" },
367
- tenantAdmin,
368
- );
369
-
370
- const results = await Promise.allSettled([
371
- stack.http.writeOk(
372
- TextContentHandlers.set,
373
- { slug: "race-test", lang: "de", title: "A", body: "from-a" },
374
- tenantAdmin,
375
- ),
376
- stack.http.writeOk(
377
- TextContentHandlers.set,
378
- { slug: "race-test", lang: "de", title: "B", body: "from-b" },
379
- tenantAdmin,
380
- ),
381
- ]);
382
- const succeeded = results.filter((r) => r.status === "fulfilled").length;
383
- expect(succeeded).toBeGreaterThanOrEqual(1);
384
-
385
- // Egal welcher gewinnt — die Row ist nach beiden Aufrufen konsistent
386
- // mit einem der beiden Werte (kein partial state).
387
- const fetched = await stack.http.queryOk<Record<string, unknown>>(
388
- TextContentQueries.bySlug,
389
- { slug: "race-test", lang: "de" },
390
- tenantAdmin,
391
- );
392
- const finalBody = fetched!["body"] as string;
393
- expect(["from-a", "from-b", "v1"]).toContain(finalBody);
394
- });
395
- });
396
-
397
- describe("text-content :: seedTextBlock", () => {
398
- test('ifExists="update" overwrites existing row (same aggregate id)', async () => {
399
- const a = await seedTextBlock(db, {
400
- tenantId: tenantAdmin.tenantId,
401
- slug: "seed-test",
402
- lang: "de",
403
- title: "v1",
404
- body: "alt",
405
- });
406
- const b = await seedTextBlock(db, {
407
- tenantId: tenantAdmin.tenantId,
408
- slug: "seed-test",
409
- lang: "de",
410
- title: "v2",
411
- body: "neu",
412
- ifExists: "update",
413
- });
414
- expect(a.id).toBe(b.id);
415
- });
416
-
417
- test('default ifExists="skip" does not overwrite on re-boot', async () => {
418
- const base = {
419
- tenantId: tenantAdmin.tenantId,
420
- slug: "seed-skip",
421
- lang: "de",
422
- };
423
- await seedTextBlock(db, {
424
- ...base,
425
- title: "Initial",
426
- body: "from seed",
427
- });
428
- await seedTextBlock(db, {
429
- ...base,
430
- title: "User edit",
431
- body: "from admin",
432
- ifExists: "update",
433
- });
434
- await seedTextBlock(db, {
435
- ...base,
436
- title: "Seed again",
437
- body: "would overwrite",
438
- });
439
-
440
- const row = await fetchOne<TextBlockRow>(db, textBlocksTable, base);
441
- expect(row).toMatchObject({ title: "User edit", body: "from admin", version: 2 });
442
-
443
- const events = await selectMany(db, eventsTable, {
444
- aggregateId: String(row!.id),
445
- });
446
- expect(events).toHaveLength(2);
447
- expect(events.map((e) => e.type)).toEqual(["text-block.created", "text-block.updated"]);
448
- });
449
-
450
- // Drift-Documentation: seedTextBlock geht direkt durch den Executor
451
- // OHNE slugSchema-Validation, set.write läuft DURCH die Validation.
452
- // Folge: seedTextBlock akzeptiert Slugs mit ":" oder "/" (legal-pages
453
- // Plattform-Seeds nutzen das für "page:index:hero.title" etc.), aber
454
- // ein User-Edit derselben Block über set.write würde mit
455
- // validation_error fail (regex `^[a-z0-9][a-z0-9-]*$`). Drift ist
456
- // **bewusst** in V.1.3 — seedTextBlock ist system-trusted (boot-fixture,
457
- // kein User-Input). V.1.4 plant ein echtes `folder`-Field statt
458
- // `:`-Separator-im-Slug, dann fällt die Drift weg.
459
- //
460
- // Dieser Test pinnt den Status quo: Editor-Form via set.write rejected
461
- // ":"-Slugs auch wenn seedTextBlock sie angelegt hat. Plus-Test
462
- // verhindert dass jemand silent seedTextBlock-Validation hinzufügt
463
- // ohne app-side seed-Slugs (z.B. legal-pages-Plattform-Seeds) zu
464
- // konvertieren.
465
- test("seedTextBlock + set.write drift: `:`-slugs sind seed-only, set.write rejected sie", async () => {
466
- // Seed mit `:`-Slug funktioniert (legal-pages-Pattern)
467
- const seeded = await seedTextBlock(db, {
468
- tenantId: tenantAdmin.tenantId,
469
- slug: "page:hero",
470
- lang: "de",
471
- title: "Seeded",
472
- body: "from-seed",
473
- });
474
- expect(seeded.id).toBeDefined();
475
-
476
- // Set.write auf demselben Slug → validation_error (kebab-only regex)
477
- const error = await stack.http.writeErr(
478
- TextContentHandlers.set,
479
- { slug: "page:hero", lang: "de", title: "User-edit", body: "from-write" },
480
- tenantAdmin,
481
- );
482
- expectErrorIncludes(error, "validation_error");
483
- });
484
-
485
- test("seedTextBlock + set.write parity: kebab-only Slugs durchlaufen beide Pfade", async () => {
486
- // Inverse-Test: für kebab-only Slugs (`page-hero`) klappen beide
487
- // Pfade. App-Builder die Edit-Form-fähige Seeds wollen, müssen
488
- // kebab-only verwenden (siehe publicstatus/bin/seed-demo.ts).
489
- await seedTextBlock(db, {
490
- tenantId: tenantAdmin.tenantId,
491
- slug: "page-hero",
492
- lang: "de",
493
- title: "Seeded",
494
- body: "from-seed",
495
- });
496
- const result = await stack.http.writeOk<{ slug: string; lang: string }>(
497
- TextContentHandlers.set,
498
- { slug: "page-hero", lang: "de", title: "User-edit", body: "from-write" },
499
- tenantAdmin,
500
- );
501
- expect(result.slug).toBe("page-hero");
502
- });
503
- });
@@ -1,86 +0,0 @@
1
- // TextContentApi — die Cross-Feature-Schnittstelle des text-content-
2
- // Features. Andere Features (z.B. legal-pages) importieren NUR den Type
3
- // hier und holen die Implementation runtime aus ctx.textContent.
4
- //
5
- // Pattern symmetrisch zu config: das Feature exportiert API-Type +
6
- // Factory, App-Bootstrap setzt die Instance via extraContext, consuming-
7
- // Features nutzen sie via require-Helper aus dem HandlerContext. So
8
- // bleiben Features durch Refactorings entkoppelt — wer textBlocksTable
9
- // umzieht oder die Query-Signatur ändert, muss nur die Factory anpassen.
10
-
11
- import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
12
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
13
- import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
14
- import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
15
- import { type TextBlockRow, textBlocksTable } from "./table";
16
-
17
- export type TextBlock = {
18
- readonly slug: string;
19
- readonly lang: string;
20
- readonly title: string;
21
- readonly body: string | null;
22
- readonly updatedAt: Date;
23
- };
24
-
25
- export type TextContentApi = {
26
- /**
27
- * Lookup eines TextBlocks by (tenantId, slug, lang). Null wenn nicht
28
- * existiert. Tenant-Scope wird vom Caller mitgegeben — kein implicit
29
- * tenantId aus Session, weil die API auch von Boot-Jobs ohne
30
- * Session-User aufgerufen wird (siehe legal-pages bootCheck).
31
- */
32
- readonly getBlock: (args: {
33
- tenantId: TenantId;
34
- slug: string;
35
- lang: string;
36
- }) => Promise<TextBlock | null>;
37
- };
38
-
39
- // @wrapper-known semantic-alias
40
- export function createTextContentApi(db: DbConnection): TextContentApi {
41
- return {
42
- getBlock: async ({ tenantId, slug, lang }) => {
43
- const row = await fetchOne<TextBlockRow>(db, textBlocksTable, { tenantId, slug, lang });
44
- if (!row) return null;
45
- return {
46
- slug: row.slug,
47
- lang: row.lang,
48
- title: row.title,
49
- body: row.body,
50
- updatedAt: row.updatedAt,
51
- };
52
- },
53
- };
54
- }
55
-
56
- // Single point of truth für "dieser Handler braucht text-content".
57
- // Wirft InternalError mit Wiring-Hinweis statt bare Error — so liest
58
- // die Debug-Session die exakte Boot-Lücke ("text-content feature not
59
- // wired into AppContext") statt eines generischen undefined-bugs.
60
- //
61
- // Pattern symmetrisch zu requireConfigResolver/requireConfigEncryption.
62
- // Akzeptiert HandlerContext + AppContext (Job-Context) — beide haben
63
- // SharedContextFields als Basis. Das narrowing geschieht via shape-check
64
- // auf das optionale `textContent`-Feld (kein Type-Lookup ins framework).
65
- export function requireTextContent(
66
- ctx: { readonly textContent?: TextContentApi } | object,
67
- callerName: string,
68
- ): TextContentApi {
69
- // @cast-boundary engine-bridge ctx ist Framework-Container (HandlerContext
70
- // | AppContext), textContent kommt per extraContext aus dem App-Bootstrap.
71
- const api = (ctx as { textContent?: TextContentApi }).textContent;
72
- if (!api) {
73
- throw new InternalError({
74
- message:
75
- `[${callerName}] ctx.textContent missing — App-Bootstrap muss ` +
76
- `extraContext: { textContent: createTextContentApi(db) } setzen ` +
77
- `(siehe text-content/README.md).`,
78
- });
79
- }
80
- return api;
81
- }
82
-
83
- // Re-export für Test-Helper die selbst eine Session-User-scoped Variante
84
- // brauchen — der Standard-Use-Case (Routes/Boot-Jobs) gibt tenantId
85
- // explizit mit, deshalb ist getBlock session-agnostisch.
86
- export type { SessionUser };
@@ -1,14 +0,0 @@
1
- [
2
- {
3
- "version": "0.165.0",
4
- "type": "fix",
5
- "title": "Legal pages seed: SYSTEM_USER instead of tenant user",
6
- "detail": "Seed now runs as SYSTEM_USER and skips identical re-seeds — prevents unnecessary DB writes."
7
- },
8
- {
9
- "version": "0.130.2",
10
- "type": "fix",
11
- "title": "Content-Tree + Config-Nav Sysadmin-Shell polish: - text-content: Leaf-Knoten tragen jetzt…",
12
- "detail": "Content-Tree + Config-Nav Sysadmin-Shell polish: - text-content: Leaf-Knoten tragen jetzt ein `file`-Icon statt eines Dots; der Editor läuft auf der Page-Shell (`Form`-Primitive mit Card statt des entfernten `FormPanelShell`). - Sidebar-Nav bekommt ein Suchfeld, das den Baum live filtert (Treffer + ihre Ancestors bleiben, zugeklappte Ordner öffnen für die Suche). - Ordner-Knoten zeigen `folder-open` wenn ausgeklappt. - NAV_ICONS um `server`, `mail`, `lock`, `hash`, `download`, `folder-open` ergänzt — SMTP-/Config-Nav-Kinder (z.B. „Email-Versand\") rendern damit ein Icon statt blank. - Verschachtelte Provider-Ordner (Content-Tree) rendern ihre Kinder in einem `<ul>` (valides HTML + Einrück-Stufe pro Tiefe) statt `<li>`-in-`<li>`. - Platform-Overview: `user:query:user:list` in der Allowlist (behebt den Overview-Crash). - @cosmicdrift/kumiko-renderer-web@0.130.2 - @cosmicdrift/kumiko-renderer@0.130.2 - @cosmicdrift/kumiko-framework@0.130.2 - @cosmicdrift/kumiko-dispatcher-live@0.130.2 - @cosmicdrift/kumiko-headless@0.130.2"
13
- }
14
- ]
@@ -1,21 +0,0 @@
1
- // @runtime client
2
- // Feature name
3
- export const TEXT_CONTENT_FEATURE = "text-content" as const;
4
-
5
- // Qualified write handler names (QN format: scope:type:name)
6
- export const TextContentHandlers = {
7
- set: "text-content:write:set",
8
- } as const;
9
-
10
- // Qualified query handler names (QN format: scope:type:name)
11
- export const TextContentQueries = {
12
- bySlug: "text-content:query:by-slug",
13
- byTenant: "text-content:query:by-tenant",
14
- } as const;
15
-
16
- // Error codes
17
- export const TextContentErrors = {
18
- notFound: "text_block_not_found",
19
- invalidSlug: "invalid_slug",
20
- invalidLang: "invalid_lang",
21
- } as const;
@@ -1,53 +0,0 @@
1
- import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
2
- import { bySlugQuery } from "./handlers/by-slug.query";
3
- import { byTenantQuery } from "./handlers/by-tenant.query";
4
- import { setWrite } from "./handlers/set.write";
5
- import { textBlockEntity } from "./table";
6
-
7
- // text-content — generischer Container für statische Texte (Impressum,
8
- // Datenschutz, FAQ, About, ToS, Marketing-Snippets). Pro
9
- // (tenantId, slug, lang) genau ein Block. Inhalt ist Markdown — die
10
- // Konvertierung zu HTML übernehmen Consumer-Features wie legal-pages
11
- // (das opt-in obendrauf-Feature für Compliance-Pages).
12
- //
13
- // Opt-in: wer keine statischen Texte braucht (interne Tools), aktiviert
14
- // das Feature gar nicht. Wer es aktiviert, hat sofort CRUD + by-slug-
15
- // query + by-tenant-list-query — Routes/Render kommen pro Use-Case
16
- // (legal-pages, Visual-Tree, etc.).
17
- //
18
- // **Visual-Tree-Integration (V.1.2)**: r.treeActions deklariert die
19
- // Edit-Actions für Cross-Feature-Linking via buildTarget. Der Handle
20
- // wird via setup-export propagiert (Memory `[EventDef-Exports-Pattern]`),
21
- // sodass andere Features compile-time-typed Cross-Feature-Edits triggern
22
- // können — siehe legal-pages's TreeProvider der text-content:edit als
23
- // Target nutzt. Der Client-side TreeProvider lebt in `web/client-plugin.ts`.
24
- export function createTextContentFeature() {
25
- return defineFeature("text-content", (r) => {
26
- r.describe(
27
- "Generic Markdown text store keyed by `(tenantId, slug, lang)` \u2014 one row per combination in the `read_text_blocks` entity table. Provides `text-content:write:set` (TenantAdmin upsert) and `text-content:query:by-slug` (anonymous-capable read); use `SYSTEM_TENANT_ID` as the tenant for app-wide texts such as imprint, privacy policy, or FAQ. Other features (e.g. `legal-pages`) read blocks without a direct code import via the `createTextContentApi` / `requireTextContent` extraContext pattern.",
28
- );
29
- r.uiHints({
30
- displayLabel: "Text Content \u00b7 Markdown Store",
31
- category: "content",
32
- recommended: false,
33
- });
34
- r.entity("text-block", textBlockEntity);
35
-
36
- const handlers = {
37
- set: r.writeHandler(setWrite),
38
- };
39
-
40
- const queries = {
41
- bySlug: r.queryHandler(bySlugQuery),
42
- byTenant: r.queryHandler(byTenantQuery),
43
- };
44
-
45
- const treeHandle = r.treeActions({
46
- edit: { args: { slug: "" as string, lang: "" as string } },
47
- list: {},
48
- create: { args: { folder: "" as string } },
49
- });
50
-
51
- return { handlers, queries, treeHandle };
52
- });
53
- }