@cosmicdrift/kumiko-bundled-features 0.2.3 → 0.4.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 (127) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/package.json +19 -14
  3. package/src/auth-email-password/handlers/change-password.write.ts +1 -1
  4. package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
  5. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
  6. package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
  7. package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
  8. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
  9. package/src/auth-email-password/handlers/login.write.ts +1 -1
  10. package/src/auth-email-password/handlers/logout.write.ts +2 -2
  11. package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  12. package/src/auth-email-password/web/auth-client.ts +1 -1
  13. package/src/billing-foundation/events.ts +1 -1
  14. package/src/billing-foundation/feature.ts +44 -47
  15. package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
  16. package/src/billing-foundation/handlers/process-event.write.ts +3 -3
  17. package/src/billing-foundation/projection.ts +1 -1
  18. package/src/billing-foundation/webhook-handler.ts +1 -1
  19. package/src/cap-counter/constants.ts +1 -1
  20. package/src/cap-counter/enforce-cap.ts +1 -1
  21. package/src/cap-counter/feature.ts +3 -7
  22. package/src/cap-counter/handlers/get-counter.query.ts +1 -1
  23. package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
  24. package/src/cap-counter/handlers/increment.write.ts +3 -3
  25. package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
  26. package/src/channel-email/email-channel.ts +1 -1
  27. package/src/channel-email/types.ts +1 -1
  28. package/src/compliance-profiles/handlers/for-tenant.query.ts +7 -6
  29. package/src/compliance-profiles/handlers/needs-profile.query.ts +1 -1
  30. package/src/compliance-profiles/handlers/set-profile.write.ts +6 -8
  31. package/src/compliance-profiles/resolve-for-tenant.ts +7 -5
  32. package/src/compliance-profiles/seeding.ts +1 -1
  33. package/src/config/resolver.ts +1 -1
  34. package/src/data-retention/_internal/parse-override.ts +3 -2
  35. package/src/data-retention/handlers/policy-for.query.ts +1 -1
  36. package/src/data-retention/keep-for.ts +1 -1
  37. package/src/data-retention/presets.ts +1 -1
  38. package/src/data-retention/resolve-for-tenant.ts +1 -1
  39. package/src/delivery/__tests__/delivery.integration.ts +6 -0
  40. package/src/delivery/delivery-service.ts +4 -12
  41. package/src/delivery/feature.ts +7 -5
  42. package/src/delivery/index.ts +0 -1
  43. package/src/delivery/testing.ts +1 -2
  44. package/src/delivery/upsert-preference.ts +1 -1
  45. package/src/feature-toggles/feature.ts +1 -1
  46. package/src/feature-toggles/handlers/list.query.ts +1 -1
  47. package/src/feature-toggles/handlers/registered.query.ts +9 -2
  48. package/src/feature-toggles/handlers/set.write.ts +3 -3
  49. package/src/file-foundation/feature.ts +1 -1
  50. package/src/file-provider-s3/feature.ts +2 -2
  51. package/src/files-provider-s3/s3-provider.ts +2 -2
  52. package/src/jobs/handlers/list.query.ts +3 -3
  53. package/src/jobs/handlers/trigger.write.ts +1 -1
  54. package/src/legal-pages/constants.ts +1 -0
  55. package/src/legal-pages/web/client-plugin.ts +82 -0
  56. package/src/legal-pages/web/index.ts +4 -0
  57. package/src/mail-foundation/feature.ts +1 -1
  58. package/src/mail-transport-smtp/feature.ts +2 -2
  59. package/src/renderer-foundation/README.md +86 -0
  60. package/src/renderer-foundation/__tests__/api.test.ts +188 -0
  61. package/src/renderer-foundation/__tests__/collect-plugins.integration.ts +101 -0
  62. package/src/renderer-foundation/api.ts +106 -0
  63. package/src/renderer-foundation/constants.ts +21 -0
  64. package/src/renderer-foundation/feature.ts +47 -0
  65. package/src/renderer-foundation/index.ts +25 -0
  66. package/src/renderer-foundation/types.ts +109 -0
  67. package/src/renderer-simple/__tests__/adapter.test.ts +50 -0
  68. package/src/renderer-simple/feature.ts +28 -3
  69. package/src/renderer-simple/simple-renderer.ts +1 -1
  70. package/src/secrets/handlers/rotate.job.ts +2 -2
  71. package/src/sessions/handlers/cleanup.job.ts +2 -2
  72. package/src/step-dispatcher/feature.ts +62 -0
  73. package/src/step-dispatcher/index.ts +16 -0
  74. package/src/step-dispatcher/mail-runner.ts +32 -0
  75. package/src/step-dispatcher/webhook-runner.ts +67 -0
  76. package/src/subscription-mollie/plugin-methods.ts +1 -1
  77. package/src/subscription-mollie/verify-webhook.ts +9 -5
  78. package/src/subscription-stripe/verify-webhook.ts +3 -3
  79. package/src/template-resolver/README.md +89 -0
  80. package/src/template-resolver/__tests__/handlers.integration.ts +403 -0
  81. package/src/template-resolver/__tests__/template-resolver.integration.ts +570 -0
  82. package/src/template-resolver/api.ts +189 -0
  83. package/src/template-resolver/constants.ts +28 -0
  84. package/src/template-resolver/feature.ts +36 -0
  85. package/src/template-resolver/handlers/archive.write.ts +42 -0
  86. package/src/template-resolver/handlers/find-by-id.query.ts +45 -0
  87. package/src/template-resolver/handlers/list.query.ts +69 -0
  88. package/src/template-resolver/handlers/publish.write.ts +45 -0
  89. package/src/template-resolver/handlers/shared.ts +41 -0
  90. package/src/template-resolver/handlers/upsert-system.write.ts +75 -0
  91. package/src/template-resolver/handlers/upsert-tenant.write.ts +98 -0
  92. package/src/template-resolver/index.ts +28 -0
  93. package/src/template-resolver/qualified-names.ts +24 -0
  94. package/src/template-resolver/table.ts +67 -0
  95. package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
  96. package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
  97. package/src/tenant/handlers/remove-member.write.ts +1 -1
  98. package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
  99. package/src/tenant/handlers/update-member-roles.write.ts +3 -3
  100. package/src/text-content/__tests__/text-content.integration.ts +54 -0
  101. package/src/text-content/constants.ts +2 -0
  102. package/src/text-content/feature.ts +20 -4
  103. package/src/text-content/handlers/by-slug.query.ts +1 -0
  104. package/src/text-content/handlers/by-tenant.query.ts +58 -0
  105. package/src/text-content/handlers/set.write.ts +24 -1
  106. package/src/text-content/seeding.ts +9 -1
  107. package/src/text-content/table.ts +6 -0
  108. package/src/text-content/web/__tests__/editor-read-only.test.tsx +125 -0
  109. package/src/text-content/web/__tests__/group-blocks.test.ts +221 -0
  110. package/src/text-content/web/client-plugin.tsx +378 -0
  111. package/src/text-content/web/index.ts +8 -0
  112. package/src/tier-engine/feature.ts +8 -8
  113. package/src/user/handlers/find-for-auth.query.ts +1 -1
  114. package/src/user/seeding.ts +2 -2
  115. package/src/user-data-rights/feature.ts +4 -3
  116. package/src/user-data-rights/handlers/cancel-deletion.write.ts +1 -1
  117. package/src/user-data-rights/handlers/download-by-job.query.ts +8 -11
  118. package/src/user-data-rights/handlers/download-by-token.query.ts +14 -16
  119. package/src/user-data-rights/handlers/export-status.query.ts +1 -1
  120. package/src/user-data-rights/handlers/request-deletion.write.ts +1 -1
  121. package/src/user-data-rights/handlers/request-export.write.ts +2 -2
  122. package/src/user-data-rights/run-export-jobs.ts +2 -2
  123. package/src/user-data-rights/run-forget-cleanup.ts +27 -28
  124. package/src/user-data-rights/run-user-export.ts +1 -1
  125. package/src/user-data-rights/token-helpers.ts +2 -2
  126. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +1 -1
  127. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +1 -1
@@ -0,0 +1,221 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import type { TreeNode } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { describe, expect, test } from "vitest";
5
+ import { type BlockSummary, groupBlocksByFolder } from "../client-plugin";
6
+
7
+ // TreeNode.children ist `readonly TreeNode[] | TreeChildrenSubscribe` —
8
+ // im Provider-Output ist die Subscribe-Form nur für deferred-children
9
+ // gedacht, groupBlocksByFolder produziert ausschließlich statische
10
+ // Array-Children. TypeGuard statt as-Cast (Memory `[Type Assertions]`).
11
+ function childrenArray(children: TreeNode["children"] | undefined): readonly TreeNode[] {
12
+ if (!Array.isArray(children)) throw new Error("expected static children-array");
13
+ return children;
14
+ }
15
+
16
+ // V.1.5d-Helper: groupBlocksByFolder gibt jetzt einen "Content"-Wrapper-
17
+ // Folder zurück. Tests wollen den Inhalt UNTER dem Wrapper prüfen.
18
+ function inside(result: readonly TreeNode[]): readonly TreeNode[] {
19
+ expect(result).toHaveLength(1);
20
+ const wrapper = result[0];
21
+ expect(wrapper?.label).toBe("Content");
22
+ expect(wrapper?.icon).toBe("folder");
23
+ return childrenArray(wrapper?.children);
24
+ }
25
+
26
+ // Helper: BlockSummary mit defaults für die nicht-test-relevanten Felder.
27
+ function block(opts: {
28
+ slug: string;
29
+ folder?: string | null;
30
+ body?: string | null;
31
+ title?: string;
32
+ }): BlockSummary {
33
+ return {
34
+ slug: opts.slug,
35
+ lang: "de",
36
+ title: opts.title ?? opts.slug,
37
+ // Nicht ?? — null soll durchgereicht werden (state="stub"-Test).
38
+ body: opts.body === undefined ? "irgendwas" : opts.body,
39
+ folder: opts.folder === undefined ? null : opts.folder,
40
+ updatedAt: "2026-05-19T00:00:00Z",
41
+ };
42
+ }
43
+
44
+ describe("groupBlocksByFolder", () => {
45
+ test("leeres Array → leeres Array", () => {
46
+ expect(groupBlocksByFolder([])).toEqual([]);
47
+ });
48
+
49
+ test("folder=null → Root-Node ohne Folder (innerhalb Content-Wrapper)", () => {
50
+ const nodes = inside(groupBlocksByFolder([block({ slug: "imprint", folder: null })]));
51
+ expect(nodes).toHaveLength(1);
52
+ const root = nodes[0];
53
+ expect(root).toBeDefined();
54
+ if (!root) return;
55
+ expect(root.label).toBe("imprint");
56
+ expect(root.target).toEqual({
57
+ featureId: "text-content",
58
+ action: "edit",
59
+ args: { slug: "imprint", lang: "de" },
60
+ });
61
+ expect(root.children).toBeUndefined();
62
+ expect(root.icon).toBeUndefined();
63
+ });
64
+
65
+ test('folder="page" → Folder-Container mit child', () => {
66
+ const nodes = inside(
67
+ groupBlocksByFolder([block({ slug: "hero", folder: "page", title: "Hero" })]),
68
+ );
69
+ expect(nodes).toHaveLength(1);
70
+ const folder = nodes[0];
71
+ expect(folder).toBeDefined();
72
+ if (!folder) return;
73
+ expect(folder.label).toBe("page");
74
+ expect(folder.icon).toBe("folder");
75
+ const children = childrenArray(folder.children);
76
+ expect(children).toHaveLength(1);
77
+ const child = children[0];
78
+ expect(child).toBeDefined();
79
+ if (!child) return;
80
+ expect(child.label).toBe("Hero");
81
+ expect(child.target?.args).toEqual({ slug: "hero", lang: "de" });
82
+ });
83
+
84
+ test("mehrere Slugs gleicher Folder → ein Folder mit mehreren children", () => {
85
+ const nodes = inside(
86
+ groupBlocksByFolder([
87
+ block({ slug: "hero", folder: "page", title: "Hero" }),
88
+ block({ slug: "cta", folder: "page", title: "CTA" }),
89
+ block({ slug: "footer", folder: "page", title: "Footer" }),
90
+ ]),
91
+ );
92
+ expect(nodes).toHaveLength(1);
93
+ const folder = nodes[0];
94
+ expect(folder).toBeDefined();
95
+ if (!folder) return;
96
+ expect(folder.label).toBe("page");
97
+ const children = childrenArray(folder.children);
98
+ expect(children).toHaveLength(3);
99
+ const labels = children.map((c) => c.label);
100
+ expect(labels).toEqual(["Hero", "CTA", "Footer"]);
101
+ });
102
+
103
+ test("mixed root + folder → root-nodes zuerst, dann Folders", () => {
104
+ const nodes = inside(
105
+ groupBlocksByFolder([
106
+ block({ slug: "imprint", folder: null }),
107
+ block({ slug: "hero", folder: "page" }),
108
+ block({ slug: "cta", folder: "page" }),
109
+ ]),
110
+ );
111
+ expect(nodes).toHaveLength(2);
112
+ expect(nodes[0]?.label).toBe("imprint");
113
+ expect(nodes[0]?.icon).toBeUndefined();
114
+ expect(nodes[1]?.label).toBe("page");
115
+ expect(nodes[1]?.icon).toBe("folder");
116
+ });
117
+
118
+ test("Folders alphabetisch sortiert (deterministisch gegen Map-order)", () => {
119
+ const nodes = inside(
120
+ groupBlocksByFolder([
121
+ block({ slug: "x", folder: "zebra" }),
122
+ block({ slug: "y", folder: "apple" }),
123
+ block({ slug: "z", folder: "mango" }),
124
+ ]),
125
+ );
126
+ const folderLabels = nodes.map((n) => n.label);
127
+ expect(folderLabels).toEqual(["apple", "mango", "zebra"]);
128
+ });
129
+
130
+ test('body=null → state="stub" (Designer-Hinweis dass Slug existiert aber leer)', () => {
131
+ const nodes = inside(groupBlocksByFolder([block({ slug: "draft", body: null })]));
132
+ expect(nodes[0]?.state).toBe("stub");
133
+ });
134
+
135
+ test('body=string → state="filled"', () => {
136
+ const nodes = inside(groupBlocksByFolder([block({ slug: "imprint", body: "content" })]));
137
+ expect(nodes[0]?.state).toBe("filled");
138
+ });
139
+
140
+ test("title leer → fallback auf slug als label", () => {
141
+ const nodes = inside(groupBlocksByFolder([block({ slug: "untitled-block", title: "" })]));
142
+ expect(nodes[0]?.label).toBe("untitled-block");
143
+ });
144
+
145
+ test('V.1.6a multi-level folder ("page/marketing") wird genested gerendert', () => {
146
+ const nodes = inside(
147
+ groupBlocksByFolder([block({ slug: "hero", folder: "page/marketing", title: "Hero" })]),
148
+ );
149
+ expect(nodes).toHaveLength(1);
150
+ const pageFolder = nodes[0];
151
+ expect(pageFolder?.label).toBe("page");
152
+ expect(pageFolder?.icon).toBe("folder");
153
+ const pageChildren = childrenArray(pageFolder?.children);
154
+ expect(pageChildren).toHaveLength(1);
155
+ const marketingFolder = pageChildren[0];
156
+ expect(marketingFolder?.label).toBe("marketing");
157
+ expect(marketingFolder?.icon).toBe("folder");
158
+ const marketingChildren = childrenArray(marketingFolder?.children);
159
+ expect(marketingChildren).toHaveLength(1);
160
+ expect(marketingChildren[0]?.label).toBe("Hero");
161
+ expect(marketingChildren[0]?.target?.args).toEqual({ slug: "hero", lang: "de" });
162
+ });
163
+
164
+ test("V.1.6a shared folder-prefix → ein gemeinsamer parent", () => {
165
+ // Zwei blocks mit verschachteltem Pfad teilen die ersten Segmente.
166
+ // page/hero + page/cta + page/marketing/banner → 1× page-folder mit
167
+ // 3 children (2 leaves + 1 sub-folder).
168
+ const nodes = inside(
169
+ groupBlocksByFolder([
170
+ block({ slug: "hero", folder: "page", title: "Hero" }),
171
+ block({ slug: "cta", folder: "page", title: "CTA" }),
172
+ block({ slug: "banner", folder: "page/marketing", title: "Banner" }),
173
+ ]),
174
+ );
175
+ expect(nodes).toHaveLength(1);
176
+ const pageFolder = nodes[0];
177
+ expect(pageFolder?.label).toBe("page");
178
+ const pageChildren = childrenArray(pageFolder?.children);
179
+ // Leaves first (Hero, CTA), dann sub-folder (marketing alphabetisch).
180
+ expect(pageChildren.map((c) => c.label)).toEqual(["Hero", "CTA", "marketing"]);
181
+ expect(pageChildren[2]?.icon).toBe("folder");
182
+ const marketingChildren = childrenArray(pageChildren[2]?.children);
183
+ expect(marketingChildren).toHaveLength(1);
184
+ expect(marketingChildren[0]?.label).toBe("Banner");
185
+ });
186
+
187
+ test("V.1.6a folder/leaf-collision: gleicher Name auf gleicher Ebene", () => {
188
+ // Edge-Case (advisor-flagged): block mit folder=null, slug="page"
189
+ // + block mit folder="page" → "page" existiert als Leaf-Root UND
190
+ // als Folder. Beide bleiben sichtbar; Folder hat Chevron + Folder-
191
+ // Icon, Leaf hat target + ist klickbar. Renderer-Pattern macht
192
+ // visuell klar dass es zwei verschiedene Dinge sind.
193
+ const nodes = inside(
194
+ groupBlocksByFolder([
195
+ block({ slug: "page", folder: null, title: "Page-Root" }),
196
+ block({ slug: "hero", folder: "page", title: "Hero" }),
197
+ ]),
198
+ );
199
+ expect(nodes).toHaveLength(2);
200
+ const leaf = nodes[0];
201
+ const folder = nodes[1];
202
+ expect(leaf?.label).toBe("Page-Root");
203
+ expect(leaf?.icon).toBeUndefined();
204
+ expect(leaf?.target).toBeDefined();
205
+ expect(folder?.label).toBe("page");
206
+ expect(folder?.icon).toBe("folder");
207
+ expect(folder?.target).toBeUndefined();
208
+ });
209
+
210
+ test("Wrapper-Folder 'Content' umschließt alle blocks", () => {
211
+ // V.1.5d Wrapper-Convention: groupBlocksByFolder gibt EINEN Knoten
212
+ // zurück (den Wrapper), Inhalt liegt eine Ebene tiefer.
213
+ const result = groupBlocksByFolder([block({ slug: "imprint" })]);
214
+ expect(result).toHaveLength(1);
215
+ const wrapper = result[0];
216
+ expect(wrapper?.label).toBe("Content");
217
+ expect(wrapper?.icon).toBe("folder");
218
+ expect(wrapper?.target).toBeUndefined();
219
+ expect(childrenArray(wrapper?.children)).toHaveLength(1);
220
+ });
221
+ });
@@ -0,0 +1,378 @@
1
+ // @runtime client
2
+ // Client-Feature-Factory für text-content Visual-Tree. Wird vom App-Code
3
+ // in createKumikoApp({ clientFeatures: [textContentClient()] }) eingehängt
4
+ // und liefert den treeProvider der Text-Blocks aus der by-tenant Query
5
+ // lädt, nach `folder`-Field gruppiert und als TreeNode[] emitted.
6
+ //
7
+ // **Folder-Gruppierung V.1.4**: Block.folder !== null → Knoten landet
8
+ // unter einem Container-Knoten mit Label folder. folder === null →
9
+ // Top-Level (root-node). Slug bleibt kebab-only validiert. Beispiele:
10
+ // - folder="page", slug="hero" → Folder "page", child "hero"
11
+ // - folder=null, slug="imprint" → root-node "imprint"
12
+ //
13
+ // **State**: TreeNode.state = "filled" wenn body gesetzt ist, sonst
14
+ // "stub" (hellgrau, Designer-Hinweis dass Slug existiert aber leer ist).
15
+ //
16
+ // **Fetch statt Subscribe**: V.1.4 ist Fetch-once beim Mount. Unsubscribe
17
+ // ist no-op. V.1.5+ kann SSE-driven Re-Emit einbauen wenn text-block-
18
+ // updated-Events propagiert werden (Stale-Tree-Fix nach save).
19
+
20
+ import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
21
+ import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
22
+ import type {
23
+ TargetRef,
24
+ TreeChildrenSubscribe,
25
+ TreeNode,
26
+ } from "@cosmicdrift/kumiko-framework/engine";
27
+ import { useDispatcher, usePrimitives, useQuery } from "@cosmicdrift/kumiko-renderer";
28
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
29
+ import { type FormEvent, type ReactNode, useEffect, useState } from "react";
30
+ import { TextContentHandlers, TextContentQueries } from "../constants";
31
+
32
+ // Exported für Unit-Test (groupBlocksByFolder ist pure-function ohne
33
+ // fetch/DOM). Public-API für externe Konsumenten ist nicht intendiert —
34
+ // sub-path-Export endet bei textContentClient().
35
+ export type BlockSummary = {
36
+ readonly slug: string;
37
+ readonly lang: string;
38
+ readonly title: string;
39
+ readonly body: string | null;
40
+ readonly folder: string | null;
41
+ readonly updatedAt: string;
42
+ };
43
+
44
+ type ByTenantResponse = {
45
+ readonly data: { readonly blocks: readonly BlockSummary[] };
46
+ };
47
+
48
+ // V.1.6a Multi-level Folder-Splitting: `folder` ist ein `/`-separierter
49
+ // Pfad (z.B. "page/marketing"). Der Tree splittet pro Segment und
50
+ // erzeugt nested Folder-Knoten:
51
+ // block.folder = null → root-leaf
52
+ // block.folder = "page" → 📁 page > leaf
53
+ // block.folder = "page/marketing" → 📁 page > 📁 marketing > leaf
54
+ //
55
+ // Walk-or-create-pattern: pro Block durch die Folder-Hierarchie laufen,
56
+ // Folder-Knoten anlegen wo nötig, Leaf am Ende anhängen. Folders pro
57
+ // Ebene alphabetisch sortiert (deterministisch).
58
+ //
59
+ // **Folder/Leaf-Collision** (advisor-Edge-Case): wenn ein Block
60
+ // `folder=null, slug="page"` UND ein anderer `folder="page", slug=...`
61
+ // existieren, leben beide an derselben Ebene — der Folder mit children
62
+ // + ein klickbarer Leaf "page" als root. Visual unterscheidbar (Folder
63
+ // hat Chevron + Folder-Icon, Leaf nicht). Kein Crash, keine Surprise.
64
+ //
65
+ // **V.1.5d Wrapper-Folder "Content"**: alle text-content-Blocks landen
66
+ // unter einem expliziten Top-Level-Folder. Empty-Tree → kein Wrapper.
67
+
68
+ type FolderNode = {
69
+ readonly leaves: TreeNode[];
70
+ readonly subFolders: Map<string, FolderNode>;
71
+ };
72
+
73
+ function newFolderNode(): FolderNode {
74
+ return { leaves: [], subFolders: new Map() };
75
+ }
76
+
77
+ function attachBlock(root: FolderNode, block: BlockSummary): void {
78
+ const leaf: TreeNode = {
79
+ label: block.title || block.slug,
80
+ target: {
81
+ featureId: "text-content",
82
+ action: "edit",
83
+ args: { slug: block.slug, lang: block.lang },
84
+ },
85
+ state: block.body ? "filled" : "stub",
86
+ };
87
+ if (block.folder === null) {
88
+ root.leaves.push(leaf);
89
+ return;
90
+ }
91
+ let cur = root;
92
+ for (const segment of block.folder.split("/")) {
93
+ let next = cur.subFolders.get(segment);
94
+ if (next === undefined) {
95
+ next = newFolderNode();
96
+ cur.subFolders.set(segment, next);
97
+ }
98
+ cur = next;
99
+ }
100
+ cur.leaves.push(leaf);
101
+ }
102
+
103
+ function renderFolderNode(node: FolderNode): TreeNode[] {
104
+ // Leaves first (root-items), dann Folders alphabetisch.
105
+ const result: TreeNode[] = [...node.leaves];
106
+ for (const folderName of [...node.subFolders.keys()].sort()) {
107
+ const subNode = node.subFolders.get(folderName);
108
+ if (subNode === undefined) continue;
109
+ result.push({
110
+ label: folderName,
111
+ icon: "folder",
112
+ state: "filled",
113
+ children: renderFolderNode(subNode),
114
+ });
115
+ }
116
+ return result;
117
+ }
118
+
119
+ export function groupBlocksByFolder(blocks: readonly BlockSummary[]): readonly TreeNode[] {
120
+ const root = newFolderNode();
121
+ for (const block of blocks) {
122
+ attachBlock(root, block);
123
+ }
124
+ const rendered = renderFolderNode(root);
125
+ if (rendered.length === 0) return [];
126
+ return [
127
+ {
128
+ label: "Content",
129
+ icon: "folder",
130
+ state: "filled",
131
+ children: rendered,
132
+ },
133
+ ];
134
+ }
135
+
136
+ const treeProvider: TreeChildrenSubscribe = () => (emit, emitError) => {
137
+ // CSRF-Header bei authenticated requests pflicht (auth-middleware
138
+ // double-submit pattern). Anonymous/Pre-Login wäre csrf-token=undefined
139
+ // → header weggelassen → server lässt die anonymous-Variante durch.
140
+ const headers: Record<string, string> = { "content-type": "application/json" };
141
+ const csrf = readCsrfToken();
142
+ if (csrf !== undefined) headers[CSRF_HEADER_NAME] = csrf;
143
+ fetch("/api/query", {
144
+ method: "POST",
145
+ headers,
146
+ body: JSON.stringify({
147
+ type: TextContentQueries.byTenant,
148
+ payload: {},
149
+ }),
150
+ })
151
+ .then(async (r) => {
152
+ if (!r.ok) {
153
+ // 403 (CSRF/auth) oder 5xx — explicit error statt silent empty.
154
+ const text = await r.text().catch(() => r.statusText);
155
+ throw new Error(`text-content load failed: ${r.status} ${text}`);
156
+ }
157
+ return r.json();
158
+ })
159
+ .then((data: ByTenantResponse) => {
160
+ const nodes = groupBlocksByFolder(data.data.blocks);
161
+ emit(nodes);
162
+ })
163
+ .catch((e) => {
164
+ // V.1.4: explicit error-Signal via emitError. ProviderBranch zeigt
165
+ // Banner + Retry-Button. Fallback auf emit([]) wenn der Consumer
166
+ // kein emitError unterstützt (Tests etc.).
167
+ if (emitError) {
168
+ emitError(e instanceof Error ? e : new Error(String(e)));
169
+ } else {
170
+ emit([]);
171
+ }
172
+ });
173
+ return () => {};
174
+ };
175
+
176
+ // V.1.3 echte Edit-Form: lädt aktuelle Werte via by-slug-query, lässt
177
+ // TenantAdmin/SystemAdmin title + body editieren, dispatcht set-write
178
+ // bei Submit. Non-Admin-User sehen die Form read-only mit Hint-Banner —
179
+ // das ist der Kumiko-Weg (Memory `[Sicherheit > Convenience]`: write-
180
+ // permission bleibt opinionated TenantAdmin-only, App-Roles erweitern
181
+ // per Dual-Role-Mapping wenn gewollt).
182
+ //
183
+ // **Stale-Tree-Caveat (V.1.4-Followup)**: TreeProvider ist fetch-once.
184
+ // Nach erfolgreichem Save flippt der visual state="stub"→"filled" in
185
+ // der Sidebar NICHT, bis der User den Workspace re-mountet. Editor selbst
186
+ // ist konsistent (lokaler Form-State trägt die neuen Werte). Echte
187
+ // Lösung: SSE-driven Tree-Refresh oder explicit cache-bust nach set-write.
188
+
189
+ type TextBlock = {
190
+ readonly slug: string;
191
+ readonly lang: string;
192
+ readonly title: string;
193
+ readonly body: string | null;
194
+ readonly folder: string | null;
195
+ readonly updatedAt: string;
196
+ };
197
+
198
+ type SetResponse = { readonly slug: string; readonly lang: string; readonly isNew: boolean };
199
+
200
+ function TextContentEditor({
201
+ target,
202
+ onClose,
203
+ }: {
204
+ readonly target: TargetRef;
205
+ readonly onClose: () => void;
206
+ }): ReactNode {
207
+ // @cast-boundary visual-tree-args — TargetRef.args ist erased zu
208
+ // Record<string, unknown>; der Resolver kennt das Action-Shape (siehe
209
+ // treeActions.edit-Definition unten) und de-erased pro Action analog
210
+ // zu Event-Payloads. Optional-Chain absorbiert fehlende Felder ohne
211
+ // throw, damit der Editor auch bei manuellem URL-Tampering nicht
212
+ // crasht (TargetRef könnte aus old localStorage / URL-State stammen).
213
+ const args = target.args as { slug?: string; lang?: string } | undefined;
214
+ const slug = args?.slug ?? "";
215
+ const lang = args?.lang ?? "";
216
+
217
+ const { Form, Field, Input, Button, Banner } = usePrimitives();
218
+ const dispatcher = useDispatcher();
219
+ const user = useShellUser();
220
+ const canWrite =
221
+ user?.roles.includes("TenantAdmin") === true || user?.roles.includes("SystemAdmin") === true;
222
+
223
+ // Load existing block via by-slug-query. Result ist entweder TextBlock
224
+ // oder null (slug existiert nicht — create-flow). useQuery returnt
225
+ // `data: T | null`, initial-loading: data=null + loading=true.
226
+ const {
227
+ data: loaded,
228
+ loading,
229
+ error: loadError,
230
+ } = useQuery<TextBlock | null>(
231
+ TextContentQueries.bySlug,
232
+ { slug, lang },
233
+ { enabled: slug !== "" && lang !== "" },
234
+ );
235
+
236
+ // Form-State unabhängig vom geladenen Block. Sync nur initial oder
237
+ // wenn target.slug+lang wechselt (User springt zwischen Knoten).
238
+ const [title, setTitle] = useState("");
239
+ const [body, setBody] = useState("");
240
+ const [folder, setFolder] = useState("");
241
+ const [submitting, setSubmitting] = useState(false);
242
+ const [saveError, setSaveError] = useState<string | null>(null);
243
+ const [savedMsg, setSavedMsg] = useState<string | null>(null);
244
+
245
+ // Sync loaded data → form state. Trigger sobald loaded-shape eindeutig
246
+ // ist (data ≠ undefined). loaded === null heißt "Block existiert noch
247
+ // nicht" — leere Form für create-flow.
248
+ useEffect(() => {
249
+ if (loading) return;
250
+ setTitle(loaded?.title ?? "");
251
+ setBody(loaded?.body ?? "");
252
+ setFolder(loaded?.folder ?? "");
253
+ setSaveError(null);
254
+ setSavedMsg(null);
255
+ }, [loading, loaded]);
256
+
257
+ const handleSave = async (): Promise<void> => {
258
+ setSubmitting(true);
259
+ setSaveError(null);
260
+ setSavedMsg(null);
261
+ try {
262
+ const result = await dispatcher.write<SetResponse>(TextContentHandlers.set, {
263
+ slug,
264
+ lang,
265
+ title,
266
+ body: body.length > 0 ? body : null,
267
+ folder: folder.length > 0 ? folder : null,
268
+ });
269
+ if (result.isSuccess) {
270
+ setSavedMsg(result.data.isNew ? "Neu angelegt." : "Gespeichert.");
271
+ } else {
272
+ setSaveError(result.error.message ?? result.error.code ?? "Speichern fehlgeschlagen.");
273
+ }
274
+ } catch (e) {
275
+ // Network-blip / dispatcher-throw — sonst bleibt submitting=true,
276
+ // Save-Button locked-forever, User klickt repeat ohne Feedback.
277
+ // Generic message reicht: konkreter Recovery-Pfad ist Retry.
278
+ setSaveError(e instanceof Error ? e.message : "Netzwerkfehler beim Speichern.");
279
+ } finally {
280
+ setSubmitting(false);
281
+ }
282
+ };
283
+
284
+ const onSubmit = (e?: FormEvent): void => {
285
+ e?.preventDefault();
286
+ void handleSave();
287
+ };
288
+
289
+ const disabled = submitting || loading || !canWrite;
290
+
291
+ return (
292
+ <div className="flex h-full flex-col">
293
+ <header className="flex items-center justify-between border-b px-6 py-4">
294
+ <div>
295
+ <h2 className="text-lg font-semibold">Text-Block bearbeiten</h2>
296
+ <p className="text-xs text-muted-foreground">
297
+ {slug || "—"} ({lang || "—"})
298
+ </p>
299
+ </div>
300
+ <Button variant="secondary" onClick={onClose}>
301
+ schlie&szlig;en
302
+ </Button>
303
+ </header>
304
+ <div className="flex-1 overflow-y-auto px-6 py-6">
305
+ <Form onSubmit={onSubmit}>
306
+ {loading && <Banner variant="loading">Lädt aktuellen Stand…</Banner>}
307
+ {loadError !== null && (
308
+ <Banner variant="error">Konnte Block nicht laden: {loadError.code}</Banner>
309
+ )}
310
+ {!canWrite && !loading && (
311
+ <Banner variant="info">
312
+ Read-only — TenantAdmin- oder SystemAdmin-Rolle f&uuml;r &Auml;nderungen erforderlich.
313
+ </Banner>
314
+ )}
315
+ <Field id="text-content-title" label="Titel" required>
316
+ <Input
317
+ kind="text"
318
+ id="text-content-title"
319
+ name="text-content-title"
320
+ value={title}
321
+ onChange={setTitle}
322
+ disabled={disabled}
323
+ required
324
+ />
325
+ </Field>
326
+ <Field id="text-content-folder" label="Ordner (optional)">
327
+ <Input
328
+ kind="text"
329
+ id="text-content-folder"
330
+ name="text-content-folder"
331
+ value={folder}
332
+ onChange={setFolder}
333
+ disabled={disabled}
334
+ placeholder="z.B. page oder legal"
335
+ />
336
+ </Field>
337
+ <Field id="text-content-body" label="Inhalt">
338
+ <Input
339
+ kind="textarea"
340
+ id="text-content-body"
341
+ name="text-content-body"
342
+ value={body}
343
+ onChange={setBody}
344
+ disabled={disabled}
345
+ rows={14}
346
+ />
347
+ </Field>
348
+ {saveError !== null && <Banner variant="error">{saveError}</Banner>}
349
+ {savedMsg !== null && <Banner variant="info">{savedMsg}</Banner>}
350
+ {canWrite && (
351
+ <Button type="submit" loading={submitting} disabled={disabled}>
352
+ {submitting ? "Speichern…" : "Speichern"}
353
+ </Button>
354
+ )}
355
+ </Form>
356
+ </div>
357
+ </div>
358
+ );
359
+ }
360
+
361
+ export function textContentClient(): ClientFeatureDefinition {
362
+ return {
363
+ name: "text-content",
364
+ treeProvider,
365
+ // V.1.5b: SSE-driven Tree-Refresh. Bei jedem text-block-Event
366
+ // (created/updated/deleted) ruft ProviderBranch den treeProvider
367
+ // neu auf → Tree-State spiegelt save sofort wider (Stale-Tree-Fix).
368
+ treeEntities: ["text-block"],
369
+ treeActions: {
370
+ edit: { args: { slug: "" as string, lang: "" as string } },
371
+ list: {},
372
+ create: { args: { folder: "" as string } },
373
+ },
374
+ resolvers: {
375
+ "text-content:edit": TextContentEditor,
376
+ },
377
+ };
378
+ }
@@ -0,0 +1,8 @@
1
+ // @runtime client
2
+ // Public exports für die Browser-Seite des text-content Features.
3
+ // Wird über den Sub-Path-Export `@cosmicdrift/kumiko-bundled-features/text-content/web`
4
+ // konsumiert — die Server-Seite (defineFeature) lebt in
5
+ // `@cosmicdrift/kumiko-bundled-features/text-content` und hat keine
6
+ // React-/DOM-Deps. Trennung bleibt sauber so wie renderer vs renderer-web.
7
+
8
+ export { textContentClient } from "./client-plugin";
@@ -197,7 +197,7 @@ export function createTierEngineFeature<
197
197
  // Invalidation: tier-assignment events update the cache.
198
198
  r.entityHook("postSave", "tier-assignment", async (result) => {
199
199
  // result.data has tenantId + tier (after entity-update merge)
200
- const data = result.data as { tenantId?: unknown; tier?: unknown };
200
+ const data = result.data as { tenantId?: unknown; tier?: unknown }; // @cast-boundary engine-payload
201
201
  // skip: defensive type-guard auf payload-shape. Bei korrekt gerenderten
202
202
  // entity-events sind beide fields immer strings; ein malformed-payload
203
203
  // (custom-handler-bug) würde hier silent zum cache-skip führen statt
@@ -210,7 +210,7 @@ export function createTierEngineFeature<
210
210
  );
211
211
  });
212
212
  r.entityHook("postDelete", "tier-assignment", async (payload) => {
213
- const data = payload.data as { tenantId?: unknown };
213
+ const data = payload.data as { tenantId?: unknown }; // @cast-boundary engine-payload
214
214
  // skip: gleiche type-guard semantik wie postSave-hook oben.
215
215
  if (typeof data.tenantId !== "string") return;
216
216
  cache.delete(data.tenantId as TenantId);
@@ -235,16 +235,16 @@ export function createTierEngineFeature<
235
235
  "tenant",
236
236
  async (result, ctx) => {
237
237
  // result-shape: kumiko-framework's SaveContext mit isNew + data
238
- const saveResult = result as { isNew?: unknown; data?: unknown };
238
+ const saveResult = result as { isNew?: unknown; data?: unknown }; // @cast-boundary engine-payload
239
239
  // skip: nur bei tenant-create (initial) — tenant-updates feuern
240
240
  // auch postSave aber wir wollen kein neues tier-assignment bei
241
241
  // re-keying oder name-update.
242
242
  if (saveResult.isNew !== true) return;
243
- const data = saveResult.data as { id?: unknown };
243
+ const data = saveResult.data as { id?: unknown }; // @cast-boundary engine-payload
244
244
  // skip: defensive type-guard. Tenant-entity hat id zwingend, aber
245
245
  // CrudExecutor's payload-shape ist runtime-unknown.
246
246
  if (typeof data.id !== "string") return;
247
- const newTenantId = data.id as TenantId;
247
+ const newTenantId = data.id as TenantId; // @cast-boundary engine-payload
248
248
  const aggregateId = tierAssignmentAggregateId(newTenantId);
249
249
 
250
250
  // skip: defensive — inTransaction phase hat ctx.db immer gesetzt,
@@ -268,7 +268,7 @@ export function createTierEngineFeature<
268
268
  // `as TenantDb` gegen future refactor.
269
269
  // skip: defensive — sollte im inTransaction nie greifen.
270
270
  if (!("raw" in ctx.db)) return;
271
- const rawDb = ctx.db.raw as DbConnection;
271
+ const rawDb = ctx.db.raw as DbConnection; // @cast-boundary db-runner
272
272
 
273
273
  // Idempotency: stream-existence-check vor create. Pattern aus
274
274
  // seedTenant.ts. Bei re-replay (rebuild) nicht versionsbumpen.
@@ -276,7 +276,7 @@ export function createTierEngineFeature<
276
276
  const [streamRow] = (await rawDb
277
277
  .select({ v: maxFn(eventsTable.version) })
278
278
  .from(eventsTable)
279
- .where(eq(eventsTable.aggregateId, aggregateId))) as StreamRow[];
279
+ .where(eq(eventsTable.aggregateId, aggregateId))) as StreamRow[]; // @cast-boundary db-row
280
280
  // skip: idempotency — aggregate-stream existiert schon (re-replay
281
281
  // nach projection-rebuild oder hook-retry). create() würde
282
282
  // version_conflict werfen + tenant-create rollback'n. Pattern aus
@@ -336,7 +336,7 @@ export function createTierEngineFeature<
336
336
  // Skalierungs-Pfad (lazy-load + LRU) ist Sprint-8b wenn echtes
337
337
  // Bedürfnis entsteht.
338
338
  type AssignmentRow = { tenantId: string; tier: string };
339
- const rows = (await deps.db.select().from(tierAssignmentTable)) as AssignmentRow[];
339
+ const rows = (await deps.db.select().from(tierAssignmentTable)) as AssignmentRow[]; // @cast-boundary db-row
340
340
  for (const row of rows) {
341
341
  cache.set(
342
342
  row.tenantId as TenantId,
@@ -30,7 +30,7 @@ export const findForAuthQuery = defineQueryHandler({
30
30
  const condition =
31
31
  query.payload.email !== undefined
32
32
  ? eq(userTable["email"], query.payload.email)
33
- : eq(userTable["id"], query.payload.id as string);
33
+ : eq(userTable["id"], query.payload.id as string); // @cast-boundary engine-payload
34
34
 
35
35
  const [row] = await ctx.db.select().from(userTable).where(condition).limit(1);
36
36
  return (row as DbRow) ?? null;