@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
@@ -0,0 +1,563 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import {
3
+ createTenantDb,
4
+ type DbConnection,
5
+ fetchOne,
6
+ selectMany,
7
+ } from "@cosmicdrift/kumiko-framework/db";
8
+ import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
10
+ import {
11
+ createTestUser,
12
+ setupTestStack,
13
+ type TestStack,
14
+ TestUsers,
15
+ unsafeCreateEntityTable,
16
+ } from "@cosmicdrift/kumiko-framework/stack";
17
+ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
18
+ import { TEXT_BLOCK_KIND } from "../constants";
19
+ import { createTemplateResolverFeature } from "../feature";
20
+ import { executor } from "../handlers/shared";
21
+ import { TemplateResolverHandlers, TemplateResolverQueries } from "../qualified-names";
22
+ import { seedTextBlock } from "../seeding";
23
+ import { type TemplateResourceRow, templateResourceEntity, templateResourcesTable } from "../table";
24
+
25
+ let stack: TestStack;
26
+ let db: DbConnection;
27
+
28
+ const systemAdmin = TestUsers.systemAdmin;
29
+ const tenantAdmin = createTestUser({ id: 2, roles: ["TenantAdmin"] });
30
+ const normalUser = createTestUser({ id: 3 });
31
+
32
+ const feature = createTemplateResolverFeature();
33
+
34
+ beforeAll(async () => {
35
+ stack = await setupTestStack({ features: [feature] });
36
+ db = stack.db;
37
+ await unsafeCreateEntityTable(db, templateResourceEntity);
38
+ await createEventsTable(db);
39
+ });
40
+
41
+ afterAll(async () => {
42
+ await stack.cleanup();
43
+ });
44
+
45
+ describe("text-blocks :: write", () => {
46
+ test("TenantAdmin can create a text block", async () => {
47
+ const result = await stack.http.writeOk<Record<string, unknown>>(
48
+ TemplateResolverHandlers.set,
49
+ {
50
+ slug: "imprint",
51
+ locale: "de",
52
+ title: "Impressum",
53
+ content: "## Angaben gemäß § 5 TMG\n\nMarc Frost",
54
+ },
55
+ tenantAdmin,
56
+ );
57
+ expect(result).toMatchObject({ slug: "imprint", locale: "de", isNew: true });
58
+ });
59
+
60
+ test("set is idempotent — second call updates existing block", async () => {
61
+ await stack.http.writeOk(
62
+ TemplateResolverHandlers.set,
63
+ { slug: "privacy", locale: "de", title: "Datenschutz v1", content: "alt" },
64
+ tenantAdmin,
65
+ );
66
+ const result = await stack.http.writeOk<Record<string, unknown>>(
67
+ TemplateResolverHandlers.set,
68
+ { slug: "privacy", locale: "de", title: "Datenschutz v2", content: "neu" },
69
+ tenantAdmin,
70
+ );
71
+ expect(result).toMatchObject({ slug: "privacy", isNew: false });
72
+
73
+ const fetched = await stack.http.queryOk<Record<string, unknown>>(
74
+ TemplateResolverQueries.bySlug,
75
+ { slug: "privacy", locale: "de" },
76
+ tenantAdmin,
77
+ );
78
+ expect(fetched).toMatchObject({ title: "Datenschutz v2", content: "neu" });
79
+ });
80
+
81
+ test("SystemAdmin can create text blocks for SYSTEM_TENANT (without TenantAdmin role)", async () => {
82
+ // SystemAdmin is global and has NO implicit TenantAdmin on its
83
+ // membership. The set-handler ACL must explicitly allow SystemAdmin,
84
+ // otherwise no one could set platform texts (e.g. imprint).
85
+ const result = await stack.http.writeOk<Record<string, unknown>>(
86
+ TemplateResolverHandlers.set,
87
+ {
88
+ slug: "system-imprint-write",
89
+ locale: "de",
90
+ title: "System-Impressum",
91
+ content: "## Plattform\n\nMarc",
92
+ },
93
+ systemAdmin,
94
+ );
95
+ expect(result).toMatchObject({ slug: "system-imprint-write", isNew: true });
96
+ });
97
+
98
+ test("normal User cannot create text blocks (access denied)", async () => {
99
+ const error = await stack.http.writeErr(
100
+ TemplateResolverHandlers.set,
101
+ { slug: "about", locale: "de", title: "Über", content: null },
102
+ normalUser,
103
+ );
104
+ expectErrorIncludes(error, "access_denied");
105
+ });
106
+
107
+ test("invalid slug rejected by schema validation", async () => {
108
+ const error = await stack.http.writeErr(
109
+ TemplateResolverHandlers.set,
110
+ { slug: "Invalid Slug!", locale: "de", title: "x", content: null },
111
+ tenantAdmin,
112
+ );
113
+ expectErrorIncludes(error, "validation_error");
114
+ });
115
+
116
+ test("SystemAdmin can write with tenantIdOverride to a different tenant (legal-pages use-case)", async () => {
117
+ // Use-case: a platform app's edit UI loads a SystemAdmin who is NOT
118
+ // a member of SYSTEM_TENANT and lets them write there.
119
+ // Without override, the text would land on systemAdmin.tenantId
120
+ // instead of SYSTEM_TENANT — legal-pages routes would never read it.
121
+ const targetTenant = createTestUser({ id: 99 }).tenantId;
122
+ const result = await stack.http.writeOk<Record<string, unknown>>(
123
+ TemplateResolverHandlers.set,
124
+ {
125
+ slug: "override-target",
126
+ locale: "de",
127
+ title: "Override-Test",
128
+ content: "via tenantIdOverride",
129
+ tenantIdOverride: targetTenant,
130
+ },
131
+ systemAdmin,
132
+ );
133
+ expect(result).toMatchObject({ slug: "override-target", isNew: true });
134
+
135
+ // Proof: text landed on the TARGET tenant, not on systemAdmin's
136
+ // own tenant. Reading with the same override returns the block.
137
+ const read = await stack.http.queryOk<Record<string, unknown>>(
138
+ TemplateResolverQueries.bySlug,
139
+ { slug: "override-target", locale: "de", tenantIdOverride: targetTenant },
140
+ systemAdmin,
141
+ );
142
+ expect(read).toMatchObject({ slug: "override-target", title: "Override-Test" });
143
+ });
144
+
145
+ test("SystemAdmin can UPDATE with tenantIdOverride (regression: stream-lookup must use override-tenantId, not user.tenantId)", async () => {
146
+ // Regression guard for 2026-05-04: with tenantIdOverride, the
147
+ // user-context for the event-store executor MUST also be remapped —
148
+ // otherwise append() lands on user.tenantId but getStreamVersion (on
149
+ // update) also looks up user.tenantId, finding only the stream on
150
+ // override-tenantId from the first write → version_conflict even
151
+ // though the projection row is there. A test with only create+override
152
+ // did not catch the bug because append=create has no stream lookup.
153
+ const targetTenant = createTestUser({ id: 77 }).tenantId;
154
+
155
+ // Step 1: create with override.
156
+ await stack.http.writeOk<Record<string, unknown>>(
157
+ TemplateResolverHandlers.set,
158
+ {
159
+ slug: "update-target",
160
+ locale: "de",
161
+ title: "v1",
162
+ content: "first",
163
+ tenantIdOverride: targetTenant,
164
+ },
165
+ systemAdmin,
166
+ );
167
+
168
+ // Step 2: UPDATE with override (same slug+lang+target). Before the
169
+ // fix: version_conflict. After the fix: clean update.
170
+ const result = await stack.http.writeOk<Record<string, unknown>>(
171
+ TemplateResolverHandlers.set,
172
+ {
173
+ slug: "update-target",
174
+ locale: "de",
175
+ title: "v2",
176
+ content: "updated",
177
+ tenantIdOverride: targetTenant,
178
+ },
179
+ systemAdmin,
180
+ );
181
+ expect(result).toMatchObject({ slug: "update-target", isNew: false });
182
+
183
+ // Proof: read returns the UPDATED content on the TARGET tenant.
184
+ const read = await stack.http.queryOk<Record<string, unknown>>(
185
+ TemplateResolverQueries.bySlug,
186
+ { slug: "update-target", locale: "de", tenantIdOverride: targetTenant },
187
+ systemAdmin,
188
+ );
189
+ expect(read).toMatchObject({ slug: "update-target", title: "v2", content: "updated" });
190
+ });
191
+
192
+ test("TenantAdmin's tenantIdOverride attempt → 403 access_denied", async () => {
193
+ // Defense-in-depth: override is SystemAdmin-only. TenantAdmin
194
+ // must NOT be able to write to other tenants — otherwise a
195
+ // tenant admin of tenant A could simply overwrite tenant B's imprint.
196
+ const otherTenant = createTestUser({ id: 88 }).tenantId;
197
+ const error = await stack.http.writeErr(
198
+ TemplateResolverHandlers.set,
199
+ {
200
+ slug: "evil-override",
201
+ locale: "de",
202
+ title: "evil",
203
+ content: null,
204
+ tenantIdOverride: otherTenant,
205
+ },
206
+ tenantAdmin,
207
+ );
208
+ expectErrorIncludes(error, "access_denied");
209
+ });
210
+
211
+ test("invalid lang rejected by schema validation", async () => {
212
+ const error = await stack.http.writeErr(
213
+ TemplateResolverHandlers.set,
214
+ { slug: "ok", locale: "DEUTSCH", title: "x", content: null },
215
+ tenantAdmin,
216
+ );
217
+ expectErrorIncludes(error, "validation_error");
218
+ });
219
+
220
+ test("update only touches the editable columns — variableSchema/status survive a save", async () => {
221
+ // set.write shares the table with the template upserts. If it wrote its
222
+ // whole field set on update, every editor save would reset a row's
223
+ // variableSchema to {} and force status back to active.
224
+ await stack.http.writeOk(
225
+ TemplateResolverHandlers.set,
226
+ { slug: "shared-row", locale: "de", title: "v1", content: "first" },
227
+ tenantAdmin,
228
+ );
229
+ const row = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
230
+ tenantId: tenantAdmin.tenantId,
231
+ slug: "shared-row",
232
+ kind: TEXT_BLOCK_KIND,
233
+ locale: "de",
234
+ });
235
+ await executor.update(
236
+ {
237
+ id: String(row!.id),
238
+ version: row!.version,
239
+ changes: { variableSchema: JSON.stringify({ firstName: "string" }), status: "archived" },
240
+ },
241
+ createSystemUser(tenantAdmin.tenantId),
242
+ createTenantDb(db, tenantAdmin.tenantId, "system"),
243
+ );
244
+
245
+ await stack.http.writeOk(
246
+ TemplateResolverHandlers.set,
247
+ { slug: "shared-row", locale: "de", title: "v2", content: "second" },
248
+ tenantAdmin,
249
+ );
250
+
251
+ const after = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
252
+ tenantId: tenantAdmin.tenantId,
253
+ slug: "shared-row",
254
+ kind: TEXT_BLOCK_KIND,
255
+ locale: "de",
256
+ });
257
+ expect(after).toMatchObject({
258
+ title: "v2",
259
+ content: "second",
260
+ variableSchema: JSON.stringify({ firstName: "string" }),
261
+ status: "archived",
262
+ });
263
+ });
264
+ });
265
+
266
+ describe("text-blocks :: query (openToAll)", () => {
267
+ test("by-slug returns existing block for matching tenant/lang", async () => {
268
+ await seedTextBlock(db, {
269
+ tenantId: tenantAdmin.tenantId,
270
+ slug: "about",
271
+ locale: "de",
272
+ title: "Über uns",
273
+ content: "Wir sind ein Team.",
274
+ });
275
+ const result = await stack.http.queryOk<Record<string, unknown>>(
276
+ TemplateResolverQueries.bySlug,
277
+ { slug: "about", locale: "de" },
278
+ tenantAdmin,
279
+ );
280
+ expect(result).toMatchObject({
281
+ slug: "about",
282
+ locale: "de",
283
+ title: "Über uns",
284
+ content: "Wir sind ein Team.",
285
+ });
286
+ });
287
+
288
+ test("by-slug returns null for missing block", async () => {
289
+ const result = await stack.http.queryOk<Record<string, unknown> | null>(
290
+ TemplateResolverQueries.bySlug,
291
+ { slug: "does-not-exist", locale: "de" },
292
+ tenantAdmin,
293
+ );
294
+ expect(result).toBeFalsy();
295
+ });
296
+
297
+ test("by-slug isolates by tenant — other tenant's block invisible", async () => {
298
+ const otherTenant = createTestUser({
299
+ id: 99,
300
+ tenantId: "11111111-1111-4111-8111-111111111111",
301
+ roles: ["TenantAdmin"],
302
+ });
303
+ await seedTextBlock(db, {
304
+ tenantId: tenantAdmin.tenantId,
305
+ slug: "tenant-only",
306
+ locale: "de",
307
+ title: "Tenant-A only",
308
+ });
309
+ const result = await stack.http.queryOk<Record<string, unknown> | null>(
310
+ TemplateResolverQueries.bySlug,
311
+ { slug: "tenant-only", locale: "de" },
312
+ otherTenant,
313
+ );
314
+ expect(result).toBeNull();
315
+ });
316
+
317
+ test("by-slug works for SystemAdmin scoped to system tenant", async () => {
318
+ await seedTextBlock(db, {
319
+ tenantId: systemAdmin.tenantId,
320
+ slug: "system-imprint",
321
+ locale: "de",
322
+ title: "System-Impressum",
323
+ content: "Plattform-Betreiber",
324
+ });
325
+ const result = await stack.http.queryOk<Record<string, unknown>>(
326
+ TemplateResolverQueries.bySlug,
327
+ { slug: "system-imprint", locale: "de" },
328
+ systemAdmin,
329
+ );
330
+ expect(result).toMatchObject({ title: "System-Impressum" });
331
+ });
332
+ });
333
+
334
+ describe("text-blocks :: edge-cases", () => {
335
+ test("body=null roundtrip — set + query liefert null body zurück", async () => {
336
+ // Realistic use-case: a tenant admin creates an empty block as a
337
+ // stub (e.g. during onboarding) and fills it in later.
338
+ await stack.http.writeOk<Record<string, unknown>>(
339
+ TemplateResolverHandlers.set,
340
+ { slug: "stub-page", locale: "de", title: "Wird noch gefüllt", content: null },
341
+ tenantAdmin,
342
+ );
343
+ const fetched = await stack.http.queryOk<Record<string, unknown>>(
344
+ TemplateResolverQueries.bySlug,
345
+ { slug: "stub-page", locale: "de" },
346
+ tenantAdmin,
347
+ );
348
+ expect(fetched).toMatchObject({ title: "Wird noch gefüllt", content: null });
349
+ });
350
+
351
+ test("body=null kann via update auf string gesetzt werden", async () => {
352
+ await stack.http.writeOk(
353
+ TemplateResolverHandlers.set,
354
+ { slug: "later-filled", locale: "de", title: "Stub", content: null },
355
+ tenantAdmin,
356
+ );
357
+ await stack.http.writeOk(
358
+ TemplateResolverHandlers.set,
359
+ { slug: "later-filled", locale: "de", title: "Stub", content: "Inhalt" },
360
+ tenantAdmin,
361
+ );
362
+ const fetched = await stack.http.queryOk<Record<string, unknown>>(
363
+ TemplateResolverQueries.bySlug,
364
+ { slug: "later-filled", locale: "de" },
365
+ tenantAdmin,
366
+ );
367
+ expect(fetched!["content"]).toBe("Inhalt");
368
+ });
369
+
370
+ test("body knapp unter max-length (200k Zeichen) wird akzeptiert", async () => {
371
+ const justBelowMax = "a".repeat(200_000);
372
+ const result = await stack.http.writeOk<Record<string, unknown>>(
373
+ TemplateResolverHandlers.set,
374
+ { slug: "max-length-ok", locale: "de", title: "Max", content: justBelowMax },
375
+ tenantAdmin,
376
+ );
377
+ expect(result).toMatchObject({ slug: "max-length-ok", isNew: true });
378
+ });
379
+
380
+ test("body über max-length (200k+1 Zeichen) → validation_error", async () => {
381
+ const overLimit = "a".repeat(200_001);
382
+ const error = await stack.http.writeErr(
383
+ TemplateResolverHandlers.set,
384
+ { slug: "max-length-fail", locale: "de", title: "Over", content: overLimit },
385
+ tenantAdmin,
386
+ );
387
+ expectErrorIncludes(error, "validation_error");
388
+ });
389
+
390
+ test("body mit XSS-Payload wird unverändert gespeichert (Markdown-Renderer ist verantwortlich für Escaping)", async () => {
391
+ // Documented behavior: text-content stores markdown 1:1.
392
+ // Consumers (e.g. legal-pages with `marked`) must decide whether
393
+ // to sanitize — see legal-pages/README.md XSS section.
394
+ const xssPayload = "## Title\n\n<script>alert('xss')</script>\n\nText.";
395
+ await stack.http.writeOk(
396
+ TemplateResolverHandlers.set,
397
+ { slug: "xss-test", locale: "de", title: "XSS", content: xssPayload },
398
+ tenantAdmin,
399
+ );
400
+ const fetched = await stack.http.queryOk<Record<string, unknown>>(
401
+ TemplateResolverQueries.bySlug,
402
+ { slug: "xss-test", locale: "de" },
403
+ tenantAdmin,
404
+ );
405
+ // Roundtrip: body stays exactly what was written in
406
+ expect(fetched!["content"]).toBe(xssPayload);
407
+ });
408
+
409
+ test("concurrent set auf gleichen (tenantId, slug, lang) — mindestens einer succeed", async () => {
410
+ // Race test: two tenant admins (or the same admin from two tabs)
411
+ // write concurrently. fetchOne+update is not atomic — if both find
412
+ // the same `existing` and both try to update, optimistic locking
413
+ // via the executor's version check kicks in.
414
+ // Expectation: one succeeds, one may throw version_conflict
415
+ // (or both succeed if sequential enough). At least one
416
+ // must go through, otherwise the race path is broken.
417
+ await stack.http.writeOk(
418
+ TemplateResolverHandlers.set,
419
+ { slug: "race-test", locale: "de", title: "Initial", content: "v1" },
420
+ tenantAdmin,
421
+ );
422
+
423
+ const results = await Promise.allSettled([
424
+ stack.http.writeOk(
425
+ TemplateResolverHandlers.set,
426
+ { slug: "race-test", locale: "de", title: "A", content: "from-a" },
427
+ tenantAdmin,
428
+ ),
429
+ stack.http.writeOk(
430
+ TemplateResolverHandlers.set,
431
+ { slug: "race-test", locale: "de", title: "B", content: "from-b" },
432
+ tenantAdmin,
433
+ ),
434
+ ]);
435
+ const succeeded = results.filter((r) => r.status === "fulfilled").length;
436
+ expect(succeeded).toBeGreaterThanOrEqual(1);
437
+
438
+ // Whichever wins — the row is consistent after both calls
439
+ // with one of the two values (no partial state).
440
+ const fetched = await stack.http.queryOk<Record<string, unknown>>(
441
+ TemplateResolverQueries.bySlug,
442
+ { slug: "race-test", locale: "de" },
443
+ tenantAdmin,
444
+ );
445
+ const finalBody = fetched!["content"] as string;
446
+ expect(["from-a", "from-b", "v1"]).toContain(finalBody);
447
+ });
448
+ });
449
+
450
+ describe("text-blocks :: seedTextBlock", () => {
451
+ test('ifExists="update" overwrites existing row (same aggregate id)', async () => {
452
+ const a = await seedTextBlock(db, {
453
+ tenantId: tenantAdmin.tenantId,
454
+ slug: "seed-test",
455
+ locale: "de",
456
+ title: "v1",
457
+ content: "alt",
458
+ });
459
+ const b = await seedTextBlock(db, {
460
+ tenantId: tenantAdmin.tenantId,
461
+ slug: "seed-test",
462
+ locale: "de",
463
+ title: "v2",
464
+ content: "neu",
465
+ ifExists: "update",
466
+ });
467
+ expect(a.id).toBe(b.id);
468
+ });
469
+
470
+ test('default ifExists="skip" does not overwrite on re-boot', async () => {
471
+ const base = {
472
+ tenantId: tenantAdmin.tenantId,
473
+ slug: "seed-skip",
474
+ locale: "de",
475
+ };
476
+ await seedTextBlock(db, {
477
+ ...base,
478
+ title: "Initial",
479
+ content: "from seed",
480
+ });
481
+ await seedTextBlock(db, {
482
+ ...base,
483
+ title: "User edit",
484
+ content: "from admin",
485
+ ifExists: "update",
486
+ });
487
+ await seedTextBlock(db, {
488
+ ...base,
489
+ title: "Seed again",
490
+ content: "would overwrite",
491
+ });
492
+
493
+ const row = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
494
+ ...base,
495
+ kind: TEXT_BLOCK_KIND,
496
+ });
497
+ expect(row).toMatchObject({ title: "User edit", content: "from admin", version: 2 });
498
+
499
+ const events = await selectMany(db, eventsTable, {
500
+ aggregateId: String(row!.id),
501
+ });
502
+ expect(events).toHaveLength(2);
503
+ // Sorted: selectMany has no ORDER BY and both events share a timestamp.
504
+ expect(events.map((e) => e.type).sort()).toEqual([
505
+ "template-resource.created",
506
+ "template-resource.updated",
507
+ ]);
508
+ });
509
+
510
+ // Drift documentation: seedTextBlock goes directly through the executor
511
+ // WITHOUT slugSchema validation, set.write goes THROUGH the validation.
512
+ // Consequence: seedTextBlock accepts slugs with ":" or "/" (legal-pages
513
+ // platform seeds use this for "page:index:hero.title" etc.), but
514
+ // a user edit of the same block via set.write would fail with
515
+ // validation_error (regex `^[a-z0-9][a-z0-9-]*$`). The drift is
516
+ // **deliberate** in V.1.3 — seedTextBlock is system-trusted (boot fixture,
517
+ // no user input). V.1.4 plans a real `folder` field instead of a
518
+ // `:` separator in the slug, then the drift goes away.
519
+ //
520
+ // This test pins the status quo: the editor form via set.write rejects
521
+ // ":" slugs even if seedTextBlock created them. This test guards
522
+ // against someone silently adding seedTextBlock validation
523
+ // without converting app-side seed slugs (e.g. legal-pages platform
524
+ // seeds).
525
+ test("seedTextBlock + set.write drift: `:`-slugs sind seed-only, set.write rejected sie", async () => {
526
+ // Seed with `:` slug works (legal-pages pattern)
527
+ const seeded = await seedTextBlock(db, {
528
+ tenantId: tenantAdmin.tenantId,
529
+ slug: "page:hero",
530
+ locale: "de",
531
+ title: "Seeded",
532
+ content: "from-seed",
533
+ });
534
+ expect(seeded.id).toBeDefined();
535
+
536
+ // set.write on the same slug → validation_error (kebab-only regex)
537
+ const error = await stack.http.writeErr(
538
+ TemplateResolverHandlers.set,
539
+ { slug: "page:hero", locale: "de", title: "User-edit", content: "from-write" },
540
+ tenantAdmin,
541
+ );
542
+ expectErrorIncludes(error, "validation_error");
543
+ });
544
+
545
+ test("seedTextBlock + set.write parity: kebab-only Slugs durchlaufen beide Pfade", async () => {
546
+ // Inverse test: for kebab-only slugs (`page-hero`) both paths
547
+ // work. App builders who want edit-form-capable seeds must
548
+ // use kebab-only (see publicstatus/bin/seed-demo.ts).
549
+ await seedTextBlock(db, {
550
+ tenantId: tenantAdmin.tenantId,
551
+ slug: "page-hero",
552
+ locale: "de",
553
+ title: "Seeded",
554
+ content: "from-seed",
555
+ });
556
+ const result = await stack.http.writeOk<{ slug: string; locale: string }>(
557
+ TemplateResolverHandlers.set,
558
+ { slug: "page-hero", locale: "de", title: "User-edit", content: "from-write" },
559
+ tenantAdmin,
560
+ );
561
+ expect(result.slug).toBe("page-hero");
562
+ });
563
+ });
@@ -7,7 +7,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
7
7
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
8
8
  import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
9
9
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
10
- import type { ContentFormat, RenderKind } from "./constants";
10
+ import type { ContentFormat, TemplateKind } from "./constants";
11
11
  import { FALLBACK_LOCALE, SYSTEM_TENANT_ID } from "./constants";
12
12
  import { type TemplateResourceRow, templateResourcesTable } from "./table";
13
13
 
@@ -19,8 +19,10 @@ export type TemplateResource = {
19
19
  readonly version: number;
20
20
  readonly tenantId: string;
21
21
  readonly slug: string;
22
- readonly kind: RenderKind;
22
+ readonly kind: TemplateKind;
23
23
  readonly locale: string;
24
+ readonly title: string | null;
25
+ readonly folder: string | null;
24
26
  readonly content: string;
25
27
  readonly contentFormat: ContentFormat;
26
28
  readonly variableSchema: Record<string, unknown>;
@@ -34,7 +36,7 @@ export type TemplateResource = {
34
36
  export type ResolveRequest = {
35
37
  readonly tenantId: TenantId;
36
38
  readonly slug: string;
37
- readonly kind: RenderKind;
39
+ readonly kind: TemplateKind;
38
40
  readonly locale: string;
39
41
  };
40
42
 
@@ -53,7 +55,7 @@ export type TemplateResolverApi = {
53
55
  readonly findExact: (args: {
54
56
  readonly tenantId: TenantId;
55
57
  readonly slug: string;
56
- readonly kind: RenderKind;
58
+ readonly kind: TemplateKind;
57
59
  readonly locale: string;
58
60
  readonly scope?: "system";
59
61
  }) => Promise<TemplateResource | null>;
@@ -108,7 +110,7 @@ async function fetchTemplate(
108
110
  db: DbConnection,
109
111
  tenantId: string,
110
112
  slug: string,
111
- kind: RenderKind,
113
+ kind: TemplateKind,
112
114
  locale: string,
113
115
  ): Promise<TemplateResourceRow | null> {
114
116
  const rows = await selectMany(
@@ -127,7 +129,7 @@ function toPublic(row: TemplateResourceRow): TemplateResource {
127
129
  // scope/status als generic text. CHECK-Constraints in der DB schränken
128
130
  // sie auf die Union-Types ein; Cast assertet das Schema-Wissen.
129
131
  // linkedResources ist ein text-column mit JSON-payload (string→string map).
130
- const kind = row.kind as RenderKind;
132
+ const kind = row.kind as TemplateKind;
131
133
  // @cast-boundary db-row — siehe kind.
132
134
  const contentFormat = row.contentFormat as ContentFormat;
133
135
  // @cast-boundary db-row — siehe kind.
@@ -144,6 +146,8 @@ function toPublic(row: TemplateResourceRow): TemplateResource {
144
146
  slug: row.slug,
145
147
  kind,
146
148
  locale: row.locale,
149
+ title: row.title,
150
+ folder: row.folder,
147
151
  content: row.content ?? "",
148
152
  contentFormat,
149
153
  variableSchema: parseJson(row.variableSchema),
@@ -168,7 +172,7 @@ function parseJson(raw: string | null): Record<string, unknown> {
168
172
  }
169
173
 
170
174
  export class TemplateNotFoundError extends Error {
171
- constructor(public readonly query: { slug: string; kind: RenderKind; locale: string }) {
175
+ constructor(public readonly query: { slug: string; kind: TemplateKind; locale: string }) {
172
176
  super(
173
177
  `[template-resolver] no template found for slug="${query.slug}" kind="${query.kind}" locale="${query.locale}" (checked tenant + system, requested + fallback locale)`,
174
178
  );
@@ -10,6 +10,28 @@ export const RENDER_KINDS = [
10
10
  ] as const;
11
11
  export type RenderKind = (typeof RENDER_KINDS)[number];
12
12
 
13
+ // Kinds without a renderer plugin: `text-block` is the Markdown text store
14
+ // (ex text-content feature), `ai-prompt` holds LLM prompts. Both are read,
15
+ // not rendered — keeping them out of RENDER_KINDS is what stops
16
+ // DEFAULT_PLUGIN_BY_KIND from demanding a plugin for them.
17
+ export const NON_RENDER_KINDS = ["text-block", "ai-prompt"] as const;
18
+ export type NonRenderKind = (typeof NON_RENDER_KINDS)[number];
19
+
20
+ // Full kind domain of the entity; RENDER_KINDS is the renderer-foundation subset.
21
+ export const TEMPLATE_KINDS = [...RENDER_KINDS, ...NON_RENDER_KINDS] as const;
22
+ export type TemplateKind = (typeof TEMPLATE_KINDS)[number];
23
+
24
+ // Kinds written through upsertSystem/upsertTenant. `text-block` is absent on
25
+ // purpose: it goes through set.write (title + folder tree); two write paths
26
+ // onto the same row would drift apart.
27
+ export const UPSERT_KINDS = [...RENDER_KINDS, "ai-prompt"] as const;
28
+ export type UpsertKind = (typeof UPSERT_KINDS)[number];
29
+
30
+ // The only kind served by the anonymous-capable by-slug/by-tenant queries and
31
+ // by set.write — mail templates and AI prompts are operational internals and
32
+ // must not leak through the anonymous path.
33
+ export const TEXT_BLOCK_KIND = "text-block" as const;
34
+
13
35
  export const CONTENT_FORMATS = ["markdown", "mjml", "html", "plain"] as const;
14
36
  export type ContentFormat = (typeof CONTENT_FORMATS)[number];
15
37