@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,28 @@
1
+ export {
2
+ createTemplateResolverApi,
3
+ type ResolveRequest,
4
+ requireTemplateResolver,
5
+ TemplateNotFoundError,
6
+ type TemplateResolverApi,
7
+ type TemplateResource,
8
+ } from "./api";
9
+ export {
10
+ CONTENT_FORMATS,
11
+ type ContentFormat,
12
+ FALLBACK_LOCALE,
13
+ RENDER_KINDS,
14
+ type RenderKind,
15
+ SYSTEM_TENANT_ID,
16
+ TEMPLATE_SCOPES,
17
+ TEMPLATE_STATUSES,
18
+ type TemplateScope,
19
+ type TemplateStatus,
20
+ } from "./constants";
21
+ export { createTemplateResolverFeature } from "./feature";
22
+ export {
23
+ TEMPLATE_RESOLVER_FEATURE,
24
+ TemplateResolverErrors,
25
+ TemplateResolverHandlers,
26
+ TemplateResolverQueries,
27
+ } from "./qualified-names";
28
+ export { type TemplateResourceRow, templateResourceEntity, templateResourcesTable } from "./table";
@@ -0,0 +1,24 @@
1
+ // @runtime client
2
+ // Feature name + qualified handler/query names (QN: scope:type:name).
3
+ export const TEMPLATE_RESOLVER_FEATURE = "template-resolver" as const;
4
+
5
+ export const TemplateResolverHandlers = {
6
+ upsertSystem: "template-resolver:write:upsert-system",
7
+ upsertTenant: "template-resolver:write:upsert-tenant",
8
+ publish: "template-resolver:write:publish",
9
+ archive: "template-resolver:write:archive",
10
+ } as const;
11
+
12
+ export const TemplateResolverQueries = {
13
+ findById: "template-resolver:query:find-by-id",
14
+ list: "template-resolver:query:list",
15
+ } as const;
16
+
17
+ export const TemplateResolverErrors = {
18
+ notFound: "template_resource_not_found",
19
+ invalidSlug: "invalid_slug",
20
+ invalidLocale: "invalid_locale",
21
+ systemAdminRequired: "system_admin_required",
22
+ alreadyActive: "template_already_active",
23
+ alreadyArchived: "template_already_archived",
24
+ } as const;
@@ -0,0 +1,67 @@
1
+ import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ createEntity,
4
+ createLongTextField,
5
+ createSelectField,
6
+ createTextField,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { CONTENT_FORMATS, RENDER_KINDS, TEMPLATE_SCOPES, TEMPLATE_STATUSES } from "./constants";
9
+
10
+ // TemplateResource — strukturierte Template-Definition mit Tenant-
11
+ // Override-Hierarchie, Locale-Fallback und Resource-Linking via
12
+ // file-foundation. Pro (tenantId, slug, kind, locale) genau eine Row;
13
+ // scope/status/version differenzieren Lifecycle.
14
+ //
15
+ // `variableSchema` + `linkedResources` sind JSON-Strings in longText
16
+ // (Pattern aus compliance-profiles — kein dedizierter jsonbField im
17
+ // createEntity-DSL). App-Layer parsed JSON, persistiert wieder als String.
18
+ export const templateResourceEntity = createEntity({
19
+ table: "read_template_resources",
20
+ fields: {
21
+ slug: createTextField({ required: true }),
22
+ kind: createSelectField({ required: true, options: [...RENDER_KINDS] }),
23
+ locale: createTextField({ required: true }),
24
+ content: createLongTextField({}),
25
+ contentFormat: createSelectField({ required: true, options: [...CONTENT_FORMATS] }),
26
+ variableSchema: createLongTextField({}),
27
+ linkedResources: createLongTextField({}),
28
+ scope: createSelectField({ required: true, options: [...TEMPLATE_SCOPES] }),
29
+ parentTemplateId: createTextField({}),
30
+ status: createSelectField({ required: true, options: [...TEMPLATE_STATUSES] }),
31
+ },
32
+ indexes: [
33
+ {
34
+ unique: true,
35
+ columns: ["tenantId", "slug", "kind", "locale"],
36
+ name: "read_template_resources_unique",
37
+ },
38
+ ],
39
+ });
40
+
41
+ export const templateResourcesTable = buildDrizzleTable(
42
+ "template-resource",
43
+ templateResourceEntity,
44
+ );
45
+
46
+ // Concrete Row-Type — single-source dafür dass die unknown-Werte die
47
+ // Drizzle aus `Record<string, unknown>` liefert genau einmal benannt
48
+ // werden (statt 12× `row["x"] as Y` Casts in Handlern + Resolver).
49
+ export type TemplateResourceRow = {
50
+ readonly id: string | number;
51
+ readonly version: number;
52
+ readonly tenantId: string;
53
+ readonly slug: string;
54
+ readonly kind: string;
55
+ readonly locale: string;
56
+ readonly content: string | null;
57
+ readonly contentFormat: string;
58
+ readonly variableSchema: string | null;
59
+ readonly linkedResources: string | null;
60
+ readonly scope: string;
61
+ readonly parentTemplateId: string | null;
62
+ readonly status: string;
63
+ readonly createdAt: Date;
64
+ readonly updatedAt: Date;
65
+ readonly createdBy: string;
66
+ readonly updatedBy: string;
67
+ };
@@ -14,6 +14,6 @@ export const activeTenantIdsQuery = defineQueryHandler({
14
14
  .from(tenantTable)
15
15
  .where(eq(tenantTable["isEnabled"], true));
16
16
 
17
- return rows.map((row) => (row as DbRow)["id"] as number);
17
+ return rows.map((row) => (row as DbRow)["id"] as number); // @cast-boundary db-row
18
18
  },
19
19
  });
@@ -61,7 +61,7 @@ export const cancelInvitationWrite = defineWriteHandler({
61
61
  const updateResult = await executor.update(
62
62
  {
63
63
  id: event.payload.invitationId,
64
- version: invitation["version"] as number,
64
+ version: invitation["version"] as number, // @cast-boundary db-row
65
65
  changes: { status: INVITATION_STATUS.cancelled },
66
66
  },
67
67
  event.user,
@@ -31,7 +31,7 @@ export const removeMemberWrite = defineWriteHandler({
31
31
  }
32
32
 
33
33
  const result = await executor.delete(
34
- { id: (existing as DbRow)["id"] as string },
34
+ { id: (existing as DbRow)["id"] as string }, // @cast-boundary db-row
35
35
  event.user,
36
36
  db,
37
37
  );
@@ -27,7 +27,7 @@ export const resolveUserIdsQuery = defineQueryHandler({
27
27
  .select({ userId: tenantMembershipsTable.userId })
28
28
  .from(tenantMembershipsTable)
29
29
  .where(eq(tenantMembershipsTable.tenantId, tenantId));
30
- return rows.map((r) => r["userId"] as number);
30
+ return rows.map((r) => r["userId"] as number); // @cast-boundary db-row
31
31
  }
32
32
 
33
33
  if (userId !== undefined) {
@@ -39,11 +39,11 @@ export const updateMemberRolesWrite = defineWriteHandler({
39
39
  // between this read and append) surfaces as version_conflict rather than
40
40
  // silent overwrite. Per-membership parallelism is rare; if it happens,
41
41
  // the client retries on the error.
42
- const row = existing as DbRow;
42
+ const row = existing as DbRow; // @cast-boundary generic-record
43
43
  const result = await executor.update(
44
44
  {
45
- id: row["id"] as string,
46
- version: row["version"] as number,
45
+ id: row["id"] as string, // @cast-boundary db-row
46
+ version: row["version"] as number, // @cast-boundary db-row
47
47
  changes: { roles: JSON.stringify(event.payload.roles) },
48
48
  },
49
49
  event.user,
@@ -412,4 +412,58 @@ describe("text-content :: seedTextBlock", () => {
412
412
  });
413
413
  expect(a.id).toBe(b.id);
414
414
  });
415
+
416
+ // Drift-Documentation: seedTextBlock geht direkt durch den Executor
417
+ // OHNE slugSchema-Validation, set.write läuft DURCH die Validation.
418
+ // Folge: seedTextBlock akzeptiert Slugs mit ":" oder "/" (legal-pages
419
+ // Plattform-Seeds nutzen das für "page:index:hero.title" etc.), aber
420
+ // ein User-Edit derselben Block über set.write würde mit
421
+ // validation_error fail (regex `^[a-z0-9][a-z0-9-]*$`). Drift ist
422
+ // **bewusst** in V.1.3 — seedTextBlock ist system-trusted (boot-fixture,
423
+ // kein User-Input). V.1.4 plant ein echtes `folder`-Field statt
424
+ // `:`-Separator-im-Slug, dann fällt die Drift weg.
425
+ //
426
+ // Dieser Test pinnt den Status quo: Editor-Form via set.write rejected
427
+ // ":"-Slugs auch wenn seedTextBlock sie angelegt hat. Plus-Test
428
+ // verhindert dass jemand silent seedTextBlock-Validation hinzufügt
429
+ // ohne app-side seed-Slugs (z.B. legal-pages-Plattform-Seeds) zu
430
+ // konvertieren.
431
+ test("seedTextBlock + set.write drift: `:`-slugs sind seed-only, set.write rejected sie", async () => {
432
+ // Seed mit `:`-Slug funktioniert (legal-pages-Pattern)
433
+ const seeded = await seedTextBlock(db, {
434
+ tenantId: tenantAdmin.tenantId,
435
+ slug: "page:hero",
436
+ lang: "de",
437
+ title: "Seeded",
438
+ body: "from-seed",
439
+ });
440
+ expect(seeded.id).toBeDefined();
441
+
442
+ // Set.write auf demselben Slug → validation_error (kebab-only regex)
443
+ const error = await stack.http.writeErr(
444
+ TextContentHandlers.set,
445
+ { slug: "page:hero", lang: "de", title: "User-edit", body: "from-write" },
446
+ tenantAdmin,
447
+ );
448
+ expectErrorIncludes(error, "validation_error");
449
+ });
450
+
451
+ test("seedTextBlock + set.write parity: kebab-only Slugs durchlaufen beide Pfade", async () => {
452
+ // Inverse-Test: für kebab-only Slugs (`page-hero`) klappen beide
453
+ // Pfade. App-Builder die Edit-Form-fähige Seeds wollen, müssen
454
+ // kebab-only verwenden (siehe publicstatus/bin/seed-demo.ts).
455
+ await seedTextBlock(db, {
456
+ tenantId: tenantAdmin.tenantId,
457
+ slug: "page-hero",
458
+ lang: "de",
459
+ title: "Seeded",
460
+ body: "from-seed",
461
+ });
462
+ const result = await stack.http.writeOk<{ slug: string; lang: string }>(
463
+ TextContentHandlers.set,
464
+ { slug: "page-hero", lang: "de", title: "User-edit", body: "from-write" },
465
+ tenantAdmin,
466
+ );
467
+ expect(result.slug).toBe("page-hero");
468
+ });
415
469
  });
@@ -1,3 +1,4 @@
1
+ // @runtime client
1
2
  // Feature name
2
3
  export const TEXT_CONTENT_FEATURE = "text-content" as const;
3
4
 
@@ -9,6 +10,7 @@ export const TextContentHandlers = {
9
10
  // Qualified query handler names (QN format: scope:type:name)
10
11
  export const TextContentQueries = {
11
12
  bySlug: "text-content:query:by-slug",
13
+ byTenant: "text-content:query:by-tenant",
12
14
  } as const;
13
15
 
14
16
  // Error codes
@@ -1,5 +1,6 @@
1
- import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { bySlugQuery } from "./handlers/by-slug.query";
3
+ import { byTenantQuery } from "./handlers/by-tenant.query";
3
4
  import { setWrite } from "./handlers/set.write";
4
5
  import { textBlockEntity } from "./table";
5
6
 
@@ -11,8 +12,16 @@ import { textBlockEntity } from "./table";
11
12
  //
12
13
  // Opt-in: wer keine statischen Texte braucht (interne Tools), aktiviert
13
14
  // das Feature gar nicht. Wer es aktiviert, hat sofort CRUD + by-slug-
14
- // query — Routes/Render kommen pro Use-Case (legal-pages, etc.).
15
- export function createTextContentFeature(): FeatureDefinition {
15
+ // query + by-tenant-list-query — Routes/Render kommen pro Use-Case
16
+ // (legal-pages, Visual-Tree, etc.).
17
+ //
18
+ // **Visual-Tree-Integration (V.1.2)**: r.treeActions deklariert die
19
+ // Edit-Actions für Cross-Feature-Linking via buildTarget. Der Handle
20
+ // wird via setup-export propagiert (Memory `[EventDef-Exports-Pattern]`),
21
+ // sodass andere Features compile-time-typed Cross-Feature-Edits triggern
22
+ // können — siehe legal-pages's TreeProvider der text-content:edit als
23
+ // Target nutzt. Der Client-side TreeProvider lebt in `web/client-plugin.ts`.
24
+ export function createTextContentFeature() {
16
25
  return defineFeature("text-content", (r) => {
17
26
  r.entity("text-block", textBlockEntity);
18
27
 
@@ -22,8 +31,15 @@ export function createTextContentFeature(): FeatureDefinition {
22
31
 
23
32
  const queries = {
24
33
  bySlug: r.queryHandler(bySlugQuery),
34
+ byTenant: r.queryHandler(byTenantQuery),
25
35
  };
26
36
 
27
- return { handlers, queries };
37
+ const treeHandle = r.treeActions({
38
+ edit: { args: { slug: "" as string, lang: "" as string } },
39
+ list: {},
40
+ create: { args: { folder: "" as string } },
41
+ });
42
+
43
+ return { handlers, queries, treeHandle };
28
44
  });
29
45
  }
@@ -49,6 +49,7 @@ export const bySlugQuery = defineQueryHandler({
49
49
  lang: row.lang,
50
50
  title: row.title,
51
51
  body: row.body,
52
+ folder: row.folder,
52
53
  updatedAt: row.updatedAt,
53
54
  };
54
55
  },
@@ -0,0 +1,58 @@
1
+ import { castTenantRows } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { eq } from "drizzle-orm";
5
+ import { z } from "zod";
6
+ import { type TextBlockRow, textBlocksTable } from "../table";
7
+
8
+ // Public-Read aller Text-Blocks für einen Tenant. Use-Case: Visual-Tree-
9
+ // Provider lädt die Slug-Liste zur Sidebar-Render. Anonymous: explizit
10
+ // in roles damit no-JWT-Visitors auch lesen können (Marketing-Sidebar
11
+ // auf Public-Pages). Tenant-Scope kommt aus query.user.tenantId; optional
12
+ // `tenantIdOverride` (SystemAdmin-only) — symmetrisch zu by-slug.query.
13
+ //
14
+ // **Listing statt single-row**: anders als by-slug returnt das hier
15
+ // `{ blocks: [...] }` mit allen Slugs des Tenants. Pro Slug nur die
16
+ // Summary-Felder (kein full body — den lädt der Editor on-demand via
17
+ // by-slug). Hält die Sidebar-Payload klein bei vielen Slugs.
18
+ export type TextBlockSummary = {
19
+ readonly slug: string;
20
+ readonly lang: string;
21
+ readonly title: string;
22
+ readonly body: 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 — nur für SystemAdmin. Symmetrisch
31
+ * zur by-slug.query und set.write Override-Logik. */
32
+ tenantIdOverride: z.string().min(1).optional(),
33
+ }),
34
+ access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
35
+ handler: async (query, ctx) => {
36
+ const override = query.payload.tenantIdOverride;
37
+ if (override !== undefined && !query.user.roles.includes("SystemAdmin")) {
38
+ throw new AccessDeniedError({
39
+ i18nKey: "textContent.errors.tenantOverrideRequiresSystemAdmin",
40
+ details: { reason: "tenant_override_requires_system_admin" },
41
+ });
42
+ }
43
+ const tenantId = override ?? query.user.tenantId;
44
+ const rows = castTenantRows<TextBlockRow>(
45
+ await ctx.db.select().from(textBlocksTable).where(eq(textBlocksTable["tenantId"], tenantId)),
46
+ );
47
+ return {
48
+ blocks: rows.map((row) => ({
49
+ slug: row.slug,
50
+ lang: row.lang,
51
+ title: row.title,
52
+ body: row.body,
53
+ folder: row.folder,
54
+ updatedAt: row.updatedAt,
55
+ })),
56
+ };
57
+ },
58
+ });
@@ -11,6 +11,17 @@ const slugSchema = z
11
11
  .max(64)
12
12
  .regex(/^[a-z0-9][a-z0-9-]*$/, "slug must be kebab-case (lowercase, digits, dashes)");
13
13
 
14
+ // Folder-Convention V.1.4: gleiches kebab-Pattern wie slug, optional
15
+ // `/`-Separator für nested folders ("legal/imprint", "page/marketing").
16
+ // Multi-level wird vom Visual-Tree-Grouping flat-rendered bis V.1.5
17
+ // rekursive Hierarchie braucht — dann erweitert sich nur der UI-Render,
18
+ // nicht das Schema.
19
+ const folderSchema = z
20
+ .string()
21
+ .min(1)
22
+ .max(128)
23
+ .regex(/^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)*$/, "folder must be kebab-case path");
24
+
14
25
  const langSchema = z
15
26
  .string()
16
27
  .min(2)
@@ -36,6 +47,11 @@ export const setWrite = defineWriteHandler({
36
47
  lang: langSchema,
37
48
  title: z.string().min(1).max(200),
38
49
  body: z.string().max(100_000).nullable(),
50
+ /** V.1.4: Folder-Pfad für Visual-Tree-Gruppierung. Optional + null
51
+ * → root-node (kein Folder). Tree groupt nach diesem Field, slug
52
+ * bleibt flach + kebab-validiert. Beispiele: "page", "legal",
53
+ * "page/marketing". */
54
+ folder: folderSchema.nullable().optional(),
39
55
  /** Optional cross-tenant write — nur für SystemAdmin. Typischer
40
56
  * use-case: legal-pages-Edit-UI lässt SystemAdmin auf
41
57
  * SYSTEM_TENANT_ID schreiben (sonst landet der text auf seinem
@@ -68,7 +84,7 @@ export const setWrite = defineWriteHandler({
68
84
  // Symmetrisch zu seedTextBlock, das TestUsers.systemAdmin (tenantId =
69
85
  // SYSTEM_TENANT) als by verwendet.
70
86
  const executorUser =
71
- override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user;
87
+ override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user; // @cast-boundary engine-bridge
72
88
 
73
89
  const existing = await fetchOne<TextBlockRow>(
74
90
  db,
@@ -78,6 +94,11 @@ export const setWrite = defineWriteHandler({
78
94
  eq(textBlocksTable["lang"], event.payload.lang),
79
95
  );
80
96
 
97
+ // V.1.4 folder: optional + null erlaubt (root-node). Optional-Chain
98
+ // mapped undefined → null damit drizzle nullable-column konsistent
99
+ // schreibt (sonst SQL-default kicked-in vs. explicit-null Unterschied).
100
+ const folder = event.payload.folder ?? null;
101
+
81
102
  if (existing) {
82
103
  const result = await executor.update(
83
104
  {
@@ -86,6 +107,7 @@ export const setWrite = defineWriteHandler({
86
107
  changes: {
87
108
  title: event.payload.title,
88
109
  body: event.payload.body,
110
+ folder,
89
111
  },
90
112
  },
91
113
  executorUser,
@@ -104,6 +126,7 @@ export const setWrite = defineWriteHandler({
104
126
  lang: event.payload.lang,
105
127
  title: event.payload.title,
106
128
  body: event.payload.body,
129
+ folder,
107
130
  tenantId,
108
131
  },
109
132
  executorUser,
@@ -24,6 +24,11 @@ export type SeedTextBlockOptions = {
24
24
  readonly lang: string;
25
25
  readonly title: string;
26
26
  readonly body?: string | null;
27
+ /** V.1.4: Folder-Pfad für Visual-Tree-Gruppierung. Optional + null =
28
+ * root-node. Seed-Pfad bypasst slugSchema/folderSchema-Validation
29
+ * (system-trusted), aber App-Builder sollten kebab-only nutzen damit
30
+ * set.write die geseedete Row später überschreiben kann. */
31
+ readonly folder?: string | null;
27
32
  readonly by?: SessionUser;
28
33
  };
29
34
 
@@ -50,12 +55,14 @@ export async function seedTextBlock(
50
55
  eq(textBlocksTable["lang"], opts.lang),
51
56
  );
52
57
 
58
+ const folder = opts.folder ?? null;
59
+
53
60
  if (existing) {
54
61
  const result = await executor.update(
55
62
  {
56
63
  id: existing.id,
57
64
  version: existing.version,
58
- changes: { title: opts.title, body: opts.body ?? null },
65
+ changes: { title: opts.title, body: opts.body ?? null, folder },
59
66
  },
60
67
  by,
61
68
  tdb,
@@ -72,6 +79,7 @@ export async function seedTextBlock(
72
79
  lang: opts.lang,
73
80
  title: opts.title,
74
81
  body: opts.body ?? null,
82
+ folder,
75
83
  tenantId: opts.tenantId,
76
84
  },
77
85
  by,
@@ -16,6 +16,11 @@ export const textBlockEntity = createEntity({
16
16
  lang: createTextField({ required: true }),
17
17
  title: createTextField({ required: true }),
18
18
  body: createTextField({}),
19
+ // V.1.4: explicit folder-Hierarchie statt `:`-Encoding im Slug.
20
+ // Visual-Tree gruppiert nach diesem Field; null/undefined → root.
21
+ // Pflicht-Constraint im set.write-Schema (kebab-only wie slug,
22
+ // damit App-Builder konsistente Naming-Conventions haben).
23
+ folder: createTextField({}),
19
24
  },
20
25
  indexes: [
21
26
  { unique: true, columns: ["tenantId", "slug", "lang"], name: "read_text_blocks_unique" },
@@ -38,6 +43,7 @@ export type TextBlockRow = {
38
43
  readonly lang: string;
39
44
  readonly title: string;
40
45
  readonly body: string | null;
46
+ readonly folder: string | null;
41
47
  readonly createdAt: Date;
42
48
  readonly updatedAt: Date;
43
49
  readonly createdBy: string;
@@ -0,0 +1,125 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import {
4
+ createStaticLocaleResolver,
5
+ LocaleProvider,
6
+ PrimitivesProvider,
7
+ } from "@cosmicdrift/kumiko-renderer";
8
+ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
9
+ import { render, screen } from "@testing-library/react";
10
+ import type { ReactNode } from "react";
11
+ import { describe, expect, test, vi } from "vitest";
12
+ import { textContentClient } from "../client-plugin";
13
+
14
+ // Mock-Setup für die drei externen Hooks die TextContentEditor benutzt.
15
+ // vi.mock + vi.fn() erlaubt pro-Test verschiedene Roles + Dispatcher-
16
+ // Antworten. Memory `[Keine Fake-Tests]`: wir testen echtes Rendering,
17
+ // nicht nur die canWrite-Bedingung.
18
+ vi.mock("@cosmicdrift/kumiko-bundled-features/auth-email-password/web", () => ({
19
+ useShellUser: vi.fn(),
20
+ }));
21
+
22
+ vi.mock("@cosmicdrift/kumiko-renderer", async () => {
23
+ const actual = await vi.importActual<typeof import("@cosmicdrift/kumiko-renderer")>(
24
+ "@cosmicdrift/kumiko-renderer",
25
+ );
26
+ return {
27
+ ...actual,
28
+ useDispatcher: vi.fn(() => ({
29
+ write: vi.fn(),
30
+ query: vi.fn(),
31
+ })),
32
+ useQuery: vi.fn(() => ({
33
+ data: { slug: "imprint", lang: "de", title: "Impressum", body: "Inhalt" },
34
+ loading: false,
35
+ error: null,
36
+ refetch: vi.fn(),
37
+ })),
38
+ };
39
+ });
40
+
41
+ import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
42
+
43
+ const TARGET = {
44
+ featureId: "text-content",
45
+ action: "edit",
46
+ args: { slug: "imprint", lang: "de" },
47
+ } as const;
48
+
49
+ function getEditor() {
50
+ const def = textContentClient();
51
+ const Editor = def.resolvers?.["text-content:edit"];
52
+ if (!Editor) throw new Error("Editor not registered");
53
+ return Editor;
54
+ }
55
+
56
+ const localeResolver = createStaticLocaleResolver();
57
+
58
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
59
+ return (
60
+ <LocaleProvider resolver={localeResolver}>
61
+ <PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
62
+ </LocaleProvider>
63
+ );
64
+ }
65
+
66
+ describe("TextContentEditor — role-based write-access", () => {
67
+ test("TenantAdmin sieht Save-Button + editable inputs", () => {
68
+ vi.mocked(useShellUser).mockReturnValue({ id: "u1", roles: ["TenantAdmin"] });
69
+ const Editor = getEditor();
70
+ render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: Wrapper });
71
+
72
+ // Save-Button gerendert (canWrite=true → Button.type=submit)
73
+ const saveButton = screen.getByRole("button", { name: /speichern/i });
74
+ expect(saveButton).toBeTruthy();
75
+ expect(saveButton.hasAttribute("disabled")).toBe(false);
76
+
77
+ // Read-only-Banner darf NICHT erscheinen
78
+ expect(screen.queryByText(/Read-only/)).toBeNull();
79
+ });
80
+
81
+ test("SystemAdmin sieht Save-Button (alternative write-role)", () => {
82
+ vi.mocked(useShellUser).mockReturnValue({ id: "u1", roles: ["SystemAdmin"] });
83
+ const Editor = getEditor();
84
+ render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: Wrapper });
85
+
86
+ expect(screen.getByRole("button", { name: /speichern/i })).toBeTruthy();
87
+ expect(screen.queryByText(/Read-only/)).toBeNull();
88
+ });
89
+
90
+ test("Editor-Role sieht Read-only-Banner + KEIN Save-Button", () => {
91
+ // Das ist der advisor-flagged Pfad — bisher unverifiziert. Editor-
92
+ // Role hat in publicstatus's Schema Zugriff auf visual-Workspace +
93
+ // by-slug-query (read), aber NICHT auf set.write. UI muss das
94
+ // explizit kommunizieren statt 403 erst beim save zu zeigen.
95
+ vi.mocked(useShellUser).mockReturnValue({ id: "u1", roles: ["Editor"] });
96
+ const Editor = getEditor();
97
+ render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: Wrapper });
98
+
99
+ expect(screen.getByText(/Read-only/)).toBeTruthy();
100
+ expect(screen.queryByRole("button", { name: /^speichern/i })).toBeNull();
101
+ });
102
+
103
+ test("Admin-Role (publicstatus-Convention, ohne TenantAdmin-dual-Tag) sieht Read-only-Banner", () => {
104
+ // Apps die NUR `Admin` (ohne `TenantAdmin`) im JWT haben, kriegen
105
+ // read-only. Dokumentiert das Dual-Role-Pattern aus publicstatus:
106
+ // wer "Admin" allein hat (Memory `[Role-Naming-Drift]`), muss
107
+ // explizit auch "TenantAdmin" im JWT tragen damit der Editor
108
+ // schreiben darf.
109
+ vi.mocked(useShellUser).mockReturnValue({ id: "u1", roles: ["Admin"] });
110
+ const Editor = getEditor();
111
+ render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: Wrapper });
112
+
113
+ expect(screen.getByText(/Read-only/)).toBeTruthy();
114
+ expect(screen.queryByRole("button", { name: /^speichern/i })).toBeNull();
115
+ });
116
+
117
+ test("Logged-out (useShellUser=undefined) sieht Read-only-Banner", () => {
118
+ vi.mocked(useShellUser).mockReturnValue(undefined);
119
+ const Editor = getEditor();
120
+ render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: Wrapper });
121
+
122
+ expect(screen.getByText(/Read-only/)).toBeTruthy();
123
+ expect(screen.queryByRole("button", { name: /^speichern/i })).toBeNull();
124
+ });
125
+ });