@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,6 +1,9 @@
1
1
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { bySlugQuery } from "./handlers/by-slug.query";
3
+ import { byTenantQuery } from "./handlers/by-tenant.query";
2
4
  import { findByIdQuery } from "./handlers/find-by-id.query";
3
5
  import { listQuery } from "./handlers/list.query";
6
+ import { setWrite } from "./handlers/set.write";
4
7
  import { archiveWrite, publishWrite } from "./handlers/toggle-status.write";
5
8
  import { upsertSystemWrite } from "./handlers/upsert-system.write";
6
9
  import { upsertTenantWrite } from "./handlers/upsert-tenant.write";
@@ -31,13 +34,25 @@ export function createTemplateResolverFeature() {
31
34
  upsertTenant: r.writeHandler(upsertTenantWrite),
32
35
  publish: r.writeHandler(publishWrite),
33
36
  archive: r.writeHandler(archiveWrite),
37
+ set: r.writeHandler(setWrite),
34
38
  };
35
39
 
36
40
  const queries = {
37
41
  findById: r.queryHandler(findByIdQuery),
38
42
  list: r.queryHandler(listQuery),
43
+ bySlug: r.queryHandler(bySlugQuery),
44
+ byTenant: r.queryHandler(byTenantQuery),
39
45
  };
40
46
 
41
- return { handlers, queries };
47
+ // Visual-tree actions for the text-block content tree. The handle is
48
+ // propagated through the setup export so other features can build
49
+ // compile-time-typed cross-feature edit targets.
50
+ const treeHandle = r.treeActions({
51
+ edit: { args: { slug: "" as string, locale: "" as string } },
52
+ list: {},
53
+ create: { args: { folder: "" as string } },
54
+ });
55
+
56
+ return { handlers, queries, treeHandle };
42
57
  });
43
58
  }
@@ -0,0 +1,54 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import {
3
+ crossTenantOverrideDenied,
4
+ defineQueryHandler,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { z } from "zod";
7
+ import { TEXT_BLOCK_KIND } from "../constants";
8
+ import { type TemplateResourceRow, templateResourcesTable } from "../table";
9
+
10
+ // Public read of a single text-block by (tenantId, slug, locale). Anonymous
11
+ // must be listed explicitly — `openToAll` alone is auth-only (regression
12
+ // guard). The kind is pinned to `text-block`: mail templates and AI prompts
13
+ // live in the same table and must not be readable without a session.
14
+ //
15
+ // Tenant scope defaults to query.user.tenantId (an anonymous context resolves
16
+ // to SYSTEM_TENANT_ID or the host-resolved tenant, depending on app setup).
17
+ // Optional `tenantIdOverride` (SystemAdmin-only) allows a cross-tenant read —
18
+ // symmetric to set.write.
19
+ export const bySlugQuery = defineQueryHandler({
20
+ name: "by-slug",
21
+ schema: z.object({
22
+ slug: z.string().min(1).max(80),
23
+ locale: z.string().min(2).max(8),
24
+ /** Optional cross-tenant read — SystemAdmin only. See set.write.ts. */
25
+ tenantIdOverride: z.string().min(1).optional(),
26
+ }),
27
+ access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
28
+ handler: async (query, ctx) => {
29
+ const override = query.payload.tenantIdOverride;
30
+ const overrideDenied = crossTenantOverrideDenied(
31
+ query.user,
32
+ override,
33
+ "templateResolver.errors.tenantOverrideRequiresSystemAdmin",
34
+ );
35
+ if (overrideDenied) throw overrideDenied;
36
+ const tenantId = override ?? query.user.tenantId;
37
+ const row = await fetchOne<TemplateResourceRow>(ctx.db, templateResourcesTable, {
38
+ tenantId,
39
+ slug: query.payload.slug,
40
+ kind: TEXT_BLOCK_KIND,
41
+ locale: query.payload.locale,
42
+ });
43
+
44
+ if (!row) return null;
45
+ return {
46
+ slug: row.slug,
47
+ locale: row.locale,
48
+ title: row.title,
49
+ content: row.content,
50
+ folder: row.folder,
51
+ updatedAt: row.updatedAt,
52
+ };
53
+ },
54
+ });
@@ -0,0 +1,57 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { castTenantRows } from "@cosmicdrift/kumiko-framework/db";
3
+ import {
4
+ crossTenantOverrideDenied,
5
+ defineQueryHandler,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { z } from "zod";
8
+ import { TEXT_BLOCK_KIND } from "../constants";
9
+ import { type TemplateResourceRow, templateResourcesTable } from "../table";
10
+
11
+ // All text-blocks of one tenant — feeds the content tree sidebar. Anonymous
12
+ // is listed explicitly so no-JWT visitors get the sidebar on public pages;
13
+ // `kind` is pinned to text-block for the same reason as in by-slug.
14
+ //
15
+ // Unlike by-slug this returns summaries for every slug. The body travels along
16
+ // because the tree marks empty blocks as stubs; full render content for a
17
+ // single block still comes from by-slug.
18
+ export type TextBlockSummary = {
19
+ readonly slug: string;
20
+ readonly locale: string;
21
+ readonly title: string | null;
22
+ readonly content: string | null;
23
+ readonly folder: string | null;
24
+ readonly updatedAt: Date;
25
+ };
26
+
27
+ export const byTenantQuery = defineQueryHandler({
28
+ name: "by-tenant",
29
+ schema: z.object({
30
+ /** Optional cross-tenant read — SystemAdmin only. Symmetric to by-slug. */
31
+ tenantIdOverride: z.string().min(1).optional(),
32
+ }),
33
+ access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
34
+ handler: async (query, ctx) => {
35
+ const override = query.payload.tenantIdOverride;
36
+ const overrideDenied = crossTenantOverrideDenied(
37
+ query.user,
38
+ override,
39
+ "templateResolver.errors.tenantOverrideRequiresSystemAdmin",
40
+ );
41
+ if (overrideDenied) throw overrideDenied;
42
+ const tenantId = override ?? query.user.tenantId;
43
+ const rows = castTenantRows<TemplateResourceRow>(
44
+ await selectMany(ctx.db, templateResourcesTable, { tenantId, kind: TEXT_BLOCK_KIND }),
45
+ );
46
+ return {
47
+ blocks: rows.map((row) => ({
48
+ slug: row.slug,
49
+ locale: row.locale,
50
+ title: row.title,
51
+ content: row.content,
52
+ folder: row.folder,
53
+ updatedAt: row.updatedAt,
54
+ })),
55
+ };
56
+ },
57
+ });
@@ -0,0 +1,110 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import {
3
+ crossTenantOverrideDenied,
4
+ defineWriteHandler,
5
+ SYSTEM_TENANT_ID,
6
+ type TenantId,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
9
+ import { z } from "zod";
10
+ import { TEXT_BLOCK_KIND } from "../constants";
11
+ import { type TemplateResourceRow, templateResourcesTable } from "../table";
12
+ import { contentFormatSchema, executor, folderSchema, localeSchema, slugSchema } from "./shared";
13
+
14
+ // Upsert of a single text-block, one operation per (tenantId, slug, locale).
15
+ // This is the authoring path for kind=text-block: unlike upsertTenant it takes
16
+ // a title and a folder, has no draft stage (text blocks are live when saved)
17
+ // and it may write onto SYSTEM_TENANT_ID, which is where legal and marketing
18
+ // copy lives. Cross-tenant writes are SystemAdmin-only.
19
+ export const setWrite = defineWriteHandler({
20
+ name: "set",
21
+ schema: z.object({
22
+ slug: slugSchema,
23
+ locale: localeSchema,
24
+ title: z.string().min(1).max(200),
25
+ content: z.string().max(200_000).nullable(),
26
+ contentFormat: contentFormatSchema.default("markdown"),
27
+ /** Folder path for the content tree. Optional + null = root node. */
28
+ folder: folderSchema.nullable().optional(),
29
+ /** Optional cross-tenant write — SystemAdmin only. Typical use: the
30
+ * editor lets a SystemAdmin write onto SYSTEM_TENANT_ID, otherwise the
31
+ * text lands on their own platform tenant and the legal routes miss it. */
32
+ tenantIdOverride: z.string().min(1).optional(),
33
+ }),
34
+ // SystemAdmin is a global role, TenantAdmin is per tenant-membership — both
35
+ // paths are needed explicitly, otherwise nobody can set SYSTEM_TENANT texts.
36
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
37
+ handler: async (event, ctx) => {
38
+ const db = ctx.db;
39
+ const override = event.payload.tenantIdOverride;
40
+ const overrideDenied = crossTenantOverrideDenied(
41
+ event.user,
42
+ override,
43
+ "templateResolver.errors.tenantOverrideRequiresSystemAdmin",
44
+ );
45
+ if (overrideDenied) return writeFailure(overrideDenied);
46
+ // @cast-boundary engine-payload — override is a zod-validated string, the
47
+ // user's own tenantId is already TenantId-branded.
48
+ const tenantId = (override ?? event.user.tenantId) as TenantId;
49
+ // With an override the executor user has to move to the target tenant too,
50
+ // otherwise the event-store stream lookup runs against event.user.tenantId
51
+ // and reports a version conflict although the projection row exists.
52
+ const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
53
+
54
+ const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
55
+ tenantId,
56
+ slug: event.payload.slug,
57
+ kind: TEXT_BLOCK_KIND,
58
+ locale: event.payload.locale,
59
+ });
60
+
61
+ const fields = {
62
+ slug: event.payload.slug,
63
+ kind: TEXT_BLOCK_KIND,
64
+ locale: event.payload.locale,
65
+ title: event.payload.title,
66
+ content: event.payload.content,
67
+ contentFormat: event.payload.contentFormat,
68
+ folder: event.payload.folder ?? null,
69
+ variableSchema: "{}",
70
+ linkedResources: "{}",
71
+ scope: tenantId === SYSTEM_TENANT_ID ? ("system" as const) : ("tenant" as const),
72
+ parentTemplateId: null,
73
+ // Text blocks have no draft stage — saving publishes, so resolveTemplate
74
+ // (which only returns active rows) finds them.
75
+ status: "active" as const,
76
+ };
77
+
78
+ if (existing) {
79
+ const result = await executor.update(
80
+ {
81
+ id: existing.id,
82
+ version: existing.version,
83
+ // Only the editable columns — slug/kind/locale are the unique key,
84
+ // and rewriting scope/status/variableSchema here would reset whatever
85
+ // the template upserts put on the row.
86
+ changes: {
87
+ title: fields.title,
88
+ content: fields.content,
89
+ contentFormat: fields.contentFormat,
90
+ folder: fields.folder,
91
+ },
92
+ },
93
+ executorUser,
94
+ db,
95
+ );
96
+ if (!result.isSuccess) return result;
97
+ return {
98
+ isSuccess: true as const,
99
+ data: { slug: event.payload.slug, locale: event.payload.locale, isNew: false },
100
+ };
101
+ }
102
+
103
+ const result = await executor.create({ ...fields, tenantId }, executorUser, db);
104
+ if (!result.isSuccess) return result;
105
+ return {
106
+ isSuccess: true as const,
107
+ data: { slug: event.payload.slug, locale: event.payload.locale, isNew: true },
108
+ };
109
+ },
110
+ });
@@ -1,6 +1,6 @@
1
1
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { z } from "zod";
3
- import { CONTENT_FORMATS, RENDER_KINDS, TEMPLATE_STATUSES } from "../constants";
3
+ import { CONTENT_FORMATS, TEMPLATE_STATUSES, UPSERT_KINDS } from "../constants";
4
4
  import { templateResourceEntity, templateResourcesTable } from "../table";
5
5
 
6
6
  // Single executor pro Bundle — Pattern aus text-content. Wird von allen
@@ -23,7 +23,16 @@ export const localeSchema = z
23
23
  .max(8)
24
24
  .regex(/^[a-z]{2}(-[a-z]{2})?$/i, "locale must be ISO 639-1 (e.g. de, en, en-us)");
25
25
 
26
- export const kindSchema = z.enum(RENDER_KINDS);
26
+ export const kindSchema = z.enum(UPSERT_KINDS);
27
+
28
+ // Folder path for the content tree: kebab segments joined by `/`.
29
+ export const folderSchema = z
30
+ .string()
31
+ .min(1)
32
+ .max(128)
33
+ .regex(/^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)*$/, "folder must be kebab-case path");
34
+
35
+ export const titleSchema = z.string().min(1).max(200);
27
36
  export const contentFormatSchema = z.enum(CONTENT_FORMATS);
28
37
  export const statusSchema = z.enum(TEMPLATE_STATUSES);
29
38
 
@@ -38,4 +47,6 @@ export const upsertPayloadSchema = z.object({
38
47
  variableSchema: z.record(z.string(), z.unknown()).default({}),
39
48
  linkedResources: z.record(z.string(), z.string()).default({}),
40
49
  parentTemplateId: z.string().min(1).optional(),
50
+ title: titleSchema.nullable().optional(),
51
+ folder: folderSchema.nullable().optional(),
41
52
  });
@@ -41,6 +41,8 @@ export const upsertSystemWrite = defineWriteHandler({
41
41
  contentFormat: event.payload.contentFormat,
42
42
  variableSchema: JSON.stringify(event.payload.variableSchema),
43
43
  linkedResources: JSON.stringify(event.payload.linkedResources),
44
+ title: event.payload.title ?? null,
45
+ folder: event.payload.folder ?? null,
44
46
  scope: "system" as const,
45
47
  parentTemplateId: event.payload.parentTemplateId ?? null,
46
48
  // System-Defaults sind sofort active (kein draft-Stage für seeds).
@@ -65,6 +65,8 @@ export const upsertTenantWrite = defineWriteHandler({
65
65
  contentFormat: event.payload.contentFormat,
66
66
  variableSchema: JSON.stringify(event.payload.variableSchema),
67
67
  linkedResources: JSON.stringify(event.payload.linkedResources),
68
+ title: event.payload.title ?? null,
69
+ folder: event.payload.folder ?? null,
68
70
  scope: "tenant" as const,
69
71
  parentTemplateId: event.payload.parentTemplateId ?? null,
70
72
  status: event.payload.status,
@@ -10,13 +10,20 @@ export {
10
10
  CONTENT_FORMATS,
11
11
  type ContentFormat,
12
12
  FALLBACK_LOCALE,
13
+ NON_RENDER_KINDS,
14
+ type NonRenderKind,
13
15
  RENDER_KINDS,
14
16
  type RenderKind,
15
17
  SYSTEM_TENANT_ID,
18
+ TEMPLATE_KINDS,
16
19
  TEMPLATE_SCOPES,
17
20
  TEMPLATE_STATUSES,
21
+ TEXT_BLOCK_KIND,
22
+ type TemplateKind,
18
23
  type TemplateScope,
19
24
  type TemplateStatus,
25
+ UPSERT_KINDS,
26
+ type UpsertKind,
20
27
  } from "./constants";
21
28
  export { createTemplateResolverFeature } from "./feature";
22
29
  export {
@@ -7,11 +7,14 @@ export const TemplateResolverHandlers = {
7
7
  upsertTenant: "template-resolver:write:upsert-tenant",
8
8
  publish: "template-resolver:write:publish",
9
9
  archive: "template-resolver:write:archive",
10
+ set: "template-resolver:write:set",
10
11
  } as const;
11
12
 
12
13
  export const TemplateResolverQueries = {
13
14
  findById: "template-resolver:query:find-by-id",
14
15
  list: "template-resolver:query:list",
16
+ bySlug: "template-resolver:query:by-slug",
17
+ byTenant: "template-resolver:query:by-tenant",
15
18
  } as const;
16
19
 
17
20
  export const TemplateResolverErrors = {
@@ -18,13 +18,13 @@ import {
18
18
  type TenantId,
19
19
  } from "@cosmicdrift/kumiko-framework/engine";
20
20
  import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
21
- import type { ContentFormat, RenderKind } from "./constants";
21
+ import { type ContentFormat, TEXT_BLOCK_KIND, type UpsertKind } from "./constants";
22
22
  import { executor } from "./handlers/shared";
23
23
  import { type TemplateResourceRow, templateResourcesTable } from "./table";
24
24
 
25
25
  export type SeedSystemTemplateOptions = {
26
26
  readonly slug: string;
27
- readonly kind: RenderKind;
27
+ readonly kind: UpsertKind;
28
28
  readonly locale: string;
29
29
  readonly content: string;
30
30
  readonly contentFormat: ContentFormat;
@@ -62,6 +62,8 @@ export async function seedSystemTemplate(
62
62
  contentFormat: opts.contentFormat,
63
63
  variableSchema,
64
64
  linkedResources,
65
+ title: null,
66
+ folder: null,
65
67
  scope: "system" as const,
66
68
  parentTemplateId,
67
69
  status: "active" as const,
@@ -94,3 +96,125 @@ export async function seedSystemTemplate(
94
96
  },
95
97
  });
96
98
  }
99
+
100
+ export type SeedTextBlockOptions = {
101
+ readonly tenantId: TenantId;
102
+ readonly slug: string;
103
+ readonly locale: string;
104
+ readonly title: string;
105
+ readonly content?: string | null;
106
+ /** Folder path for the content tree; optional + null = root node. The seed
107
+ * path skips slug/folder validation (system-trusted), but app builders
108
+ * should stay kebab-only so set.write can overwrite the row later. */
109
+ readonly folder?: string | null;
110
+ readonly contentFormat?: ContentFormat;
111
+ readonly by?: SessionUser;
112
+ readonly ifExists?: SeedIfExists;
113
+ };
114
+
115
+ // Boot/test seed for kind=text-block. Same projection path as set.write,
116
+ // without the access check. Default ifExists="skip".
117
+ export async function seedTextBlock(
118
+ db: DbConnection,
119
+ opts: SeedTextBlockOptions,
120
+ ): Promise<{ id: string }> {
121
+ const by = opts.by ?? createSystemUser(opts.tenantId);
122
+ const tdb = createTenantDb(db, opts.tenantId, "system");
123
+
124
+ const existingRow = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
125
+ tenantId: opts.tenantId,
126
+ slug: opts.slug,
127
+ kind: TEXT_BLOCK_KIND,
128
+ locale: opts.locale,
129
+ });
130
+ const existing = existingRow
131
+ ? { id: String(existingRow.id), version: existingRow.version }
132
+ : null;
133
+
134
+ const fields = {
135
+ slug: opts.slug,
136
+ kind: TEXT_BLOCK_KIND,
137
+ locale: opts.locale,
138
+ title: opts.title,
139
+ content: opts.content ?? null,
140
+ contentFormat: opts.contentFormat ?? ("markdown" as const),
141
+ folder: opts.folder ?? null,
142
+ variableSchema: "{}",
143
+ linkedResources: "{}",
144
+ scope: opts.tenantId === SYSTEM_TENANT_ID ? ("system" as const) : ("tenant" as const),
145
+ parentTemplateId: null,
146
+ status: "active" as const,
147
+ };
148
+
149
+ return runEventStoreSeed({
150
+ existing,
151
+ ifExists: opts.ifExists,
152
+ create: async () => {
153
+ const result = await executor.create({ ...fields, tenantId: opts.tenantId }, by, tdb);
154
+ if (!result.isSuccess) {
155
+ throw new Error(`seedTextBlock create failed: ${JSON.stringify(result)}`);
156
+ }
157
+ // @cast-boundary db-row — executor.create returns the inserted row from
158
+ // INSERT/RETURNING; id is guaranteed by the PK in the returning clause.
159
+ const data = result.data as Partial<TemplateResourceRow>;
160
+ if (data.id === undefined) {
161
+ throw new Error("seedTextBlock: executor.create did not return an id");
162
+ }
163
+ return { id: String(data.id) };
164
+ },
165
+ update: async (row) => {
166
+ // Skip no-op updates so a legal re-seed on every pod boot does not bump
167
+ // version/updatedAt (ETag, "last changed", event-store growth).
168
+ if (
169
+ existingRow?.title === fields.title &&
170
+ existingRow?.content === fields.content &&
171
+ existingRow?.folder === fields.folder
172
+ ) {
173
+ return { id: String(row.id) };
174
+ }
175
+ const result = await executor.update(
176
+ {
177
+ id: row.id,
178
+ version: row.version,
179
+ changes: { title: fields.title, content: fields.content, folder: fields.folder },
180
+ },
181
+ by,
182
+ tdb,
183
+ );
184
+ if (!result.isSuccess) {
185
+ throw new Error(`seedTextBlock update failed: ${JSON.stringify(result)}`);
186
+ }
187
+ return { id: String(row.id) };
188
+ },
189
+ });
190
+ }
191
+
192
+ export type LegalContentBlock = {
193
+ readonly slug: string;
194
+ readonly locale: string;
195
+ readonly title: string;
196
+ readonly content: string;
197
+ };
198
+
199
+ // Boot seed for legal/marketing copy from a JSON template. `ifExists: "update"`
200
+ // is load-bearing: the template is authoritative — otherwise legally required
201
+ // additions never reach rows that were seeded once.
202
+ export async function seedLegalContentFromJson(
203
+ db: DbConnection,
204
+ blocks: readonly LegalContentBlock[],
205
+ opts: { readonly tenantId?: TenantId; readonly by?: SessionUser } = {},
206
+ ): Promise<void> {
207
+ const tenantId = opts.tenantId ?? (SYSTEM_TENANT_ID as TenantId);
208
+ const by = opts.by ?? createSystemUser(tenantId);
209
+ for (const block of blocks) {
210
+ await seedTextBlock(db, {
211
+ tenantId,
212
+ slug: block.slug,
213
+ locale: block.locale,
214
+ title: block.title,
215
+ content: block.content,
216
+ ifExists: "update",
217
+ by,
218
+ });
219
+ }
220
+ }
@@ -5,7 +5,7 @@ import {
5
5
  createSelectField,
6
6
  createTextField,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
- import { CONTENT_FORMATS, RENDER_KINDS, TEMPLATE_SCOPES, TEMPLATE_STATUSES } from "./constants";
8
+ import { CONTENT_FORMATS, TEMPLATE_KINDS, TEMPLATE_SCOPES, TEMPLATE_STATUSES } from "./constants";
9
9
 
10
10
  // TemplateResource — strukturierte Template-Definition mit Tenant-
11
11
  // Override-Hierarchie, Locale-Fallback und Resource-Linking via
@@ -19,8 +19,15 @@ export const templateResourceEntity = createEntity({
19
19
  table: "read_template_resources",
20
20
  fields: {
21
21
  slug: createTextField({ required: true }),
22
- kind: createSelectField({ required: true, options: [...RENDER_KINDS] }),
22
+ kind: createSelectField({ required: true, options: [...TEMPLATE_KINDS] }),
23
23
  locale: createTextField({ required: true }),
24
+ // Human-facing heading for kind="text-block" (legal pages, marketing
25
+ // copy). Nullable because render templates carry their subject inside
26
+ // the body; a NOT NULL here would break every existing mail row.
27
+ title: createTextField({}),
28
+ // Folder path for the content tree, `/`-separated ("page/marketing").
29
+ // null = root node.
30
+ folder: createTextField({}),
24
31
  // Template-body is authored by TenantAdmin/Operator (email-templates etc.),
25
32
  // business data — kein end-user UGC.
26
33
  content: createLongTextField({ allowPlaintext: "is-business-data" }),
@@ -52,6 +59,8 @@ export type TemplateResourceRow = {
52
59
  readonly slug: string;
53
60
  readonly kind: string;
54
61
  readonly locale: string;
62
+ readonly title: string | null;
63
+ readonly folder: string | null;
55
64
  readonly content: string | null;
56
65
  readonly contentFormat: string;
57
66
  readonly variableSchema: string | null;
@@ -1,30 +1,30 @@
1
1
  import { afterEach, describe, expect, mock, test } from "bun:test";
2
2
  import type { TreeNode } from "@cosmicdrift/kumiko-framework/engine";
3
- import { textContentClient } from "../client-plugin";
3
+ import { textBlocksClient } from "../client-plugin";
4
4
 
5
- // Deckt die drei neuen Migrations-Pfade (advisor-Gap): navId-Attach + SSE-
6
- // Entities, no-leak ohne navId (conditional-spread), und der Unwrap (Provider
7
- // emittiert die Folder/Leaves direkt, NICHT unter dem "Content"-Wrapper).
8
- // Der Provider fetcht → fetch wird gemockt.
5
+ // Covers the three new migration paths (advisor gap): navId attach + SSE
6
+ // entities, no-leak without navId (conditional spread), and the unwrap (the
7
+ // provider emits the folder/leaves directly, NOT under the "Content" wrapper).
8
+ // The provider fetches → fetch is mocked.
9
9
 
10
- describe("textContentClient — shape", () => {
10
+ describe("textBlocksClient — shape", () => {
11
11
  test("ohne navId: kein navProvider/navEntities (no-leak), aber Resolver bleibt", () => {
12
- const def = textContentClient();
13
- expect(def.name).toBe("text-content");
12
+ const def = textBlocksClient();
13
+ expect(def.name).toBe("template-resolver");
14
14
  expect(def.navProviders).toBeUndefined();
15
15
  expect(def.navEntities).toBeUndefined();
16
- expect(def.resolvers?.["text-content:edit"]).toBeDefined();
16
+ expect(def.resolvers?.["template-resolver:edit"]).toBeDefined();
17
17
  });
18
18
 
19
19
  test("mit navId: Provider + SSE-Entities unter exakt dieser QN", () => {
20
20
  const navId = "publicstatus:nav:content";
21
- const def = textContentClient({ navId });
21
+ const def = textBlocksClient({ navId });
22
22
  expect(Object.keys(def.navProviders ?? {})).toEqual([navId]);
23
- expect(def.navEntities?.[navId]).toEqual(["text-block"]);
23
+ expect(def.navEntities?.[navId]).toEqual(["template-resource"]);
24
24
  });
25
25
  });
26
26
 
27
- describe("textContentClient — Provider unwrappt den Content-Container", () => {
27
+ describe("textBlocksClient — Provider unwrappt den Content-Container", () => {
28
28
  const origFetch = globalThis.fetch;
29
29
  afterEach(() => {
30
30
  globalThis.fetch = origFetch;
@@ -32,11 +32,18 @@ describe("textContentClient — Provider unwrappt den Content-Container", () =>
32
32
 
33
33
  test("emittiert Folder/Leaves direkt, kein 'Content'-Wrapper-Knoten", async () => {
34
34
  const blocks = [
35
- { slug: "imprint", lang: "de", title: "Imprint", body: "x", folder: null, updatedAt: "" },
36
- { slug: "hero", lang: "de", title: "Hero", body: null, folder: "page", updatedAt: "" },
35
+ {
36
+ slug: "imprint",
37
+ locale: "de",
38
+ title: "Imprint",
39
+ content: "x",
40
+ folder: null,
41
+ updatedAt: "",
42
+ },
43
+ { slug: "hero", locale: "de", title: "Hero", content: null, folder: "page", updatedAt: "" },
37
44
  ];
38
- // Test-Mock-Grenze: bun-Mock deckt nicht die volle fetch-Signatur
39
- // (preconnect etc.) — Double-Cast bewusst, nur dieser Test ruft fetch.
45
+ // Test-mock boundary: bun's mock doesn't cover the full fetch signature
46
+ // (preconnect etc.) — double-cast is deliberate, only this test calls fetch.
40
47
  globalThis.fetch = mock(
41
48
  async () =>
42
49
  new Response(JSON.stringify({ data: { blocks } }), {
@@ -46,19 +53,19 @@ describe("textContentClient — Provider unwrappt den Content-Container", () =>
46
53
  ) as unknown as typeof fetch;
47
54
 
48
55
  const navId = "x:nav:content";
49
- const provider = textContentClient({ navId }).navProviders?.[navId];
56
+ const provider = textBlocksClient({ navId }).navProviders?.[navId];
50
57
  if (provider === undefined) throw new Error("provider missing");
51
58
 
52
59
  let emitted: readonly TreeNode[] | undefined;
53
60
  provider()((nodes) => {
54
61
  emitted = nodes;
55
62
  });
56
- // fetch().then(...) ist async → eine Makrotask abwarten bis emit lief.
63
+ // fetch().then(...) is async → wait one macrotask until emit has run.
57
64
  await new Promise((r) => setTimeout(r, 0));
58
65
 
59
66
  expect(emitted).toBeDefined();
60
67
  const labels = (emitted ?? []).map((n) => n.label).sort();
61
- // Kein "Content"-Wrapper — root-leaf "Imprint" + folder "page" direkt.
68
+ // No "Content" wrapper — root-leaf "Imprint" + folder "page" directly.
62
69
  expect(labels).not.toContain("Content");
63
70
  expect(labels).toEqual(["Imprint", "page"]);
64
71
  });