@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,86 @@
1
+ # renderer-foundation
2
+
3
+ Plugin-Foundation für Renderer (Notification, HTML-Mail, PDF, Image). Plugins (`renderer-simple`, `renderer-mail-html`, `renderer-puppeteer-client`) registrieren sich via `r.useExtension("renderer", "<name>", { kinds, render })`.
4
+
5
+ **Plan-Doc:** [`kumiko-platform/docs/plans/features/renderer-foundation.md`](../../../../../../kumiko-platform/docs/plans/features/renderer-foundation.md)
6
+
7
+ ## Mount
8
+
9
+ ```typescript
10
+ import {
11
+ collectRendererPlugins,
12
+ createRendererFoundationApi,
13
+ createRendererFoundationFeature,
14
+ } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
15
+
16
+ const features = [
17
+ createTemplateResolverFeature(),
18
+ createRendererFoundationFeature(),
19
+ createRendererSimpleFeature(), // Plugin
20
+ createRendererMailHtmlFeature(), // Plugin (enterprise)
21
+ // weitere Plugins...
22
+ ];
23
+
24
+ const app = createKumikoApp({
25
+ features,
26
+ extraContext: ({ registry }) => ({
27
+ rendererFoundation: createRendererFoundationApi(
28
+ collectRendererPlugins(registry),
29
+ ),
30
+ }),
31
+ });
32
+ ```
33
+
34
+ ## Konsumtion
35
+
36
+ ```typescript
37
+ import { requireRendererFoundation } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
38
+
39
+ async function sendMail(ctx, tenantId) {
40
+ const foundation = requireRendererFoundation(ctx, "sendMail");
41
+ const renderer = foundation.createRendererForTenant({ tenantId, kind: "mail-html" });
42
+ const result = await renderer.render(
43
+ {
44
+ kind: "mail-html",
45
+ payload: { content: "Hello {{name}}", contentFormat: "markdown", variables: { name: "Frau Schmidt" } },
46
+ },
47
+ { db: ctx.db, registry: ctx.registry, tenantId },
48
+ );
49
+ // result.html, result.text
50
+ }
51
+ ```
52
+
53
+ Plugins ohne Service-Deps (`renderer-simple`) ignorieren den zweiten `ctx`-Parameter.
54
+
55
+ ## Plugin-Auswahl-Reihenfolge
56
+
57
+ 1. Tenant-Override (Config-Key `rendererPluginByKind`, z.B. `{ "mail-html": "mail-html" }`)
58
+ 2. `DEFAULT_PLUGIN_BY_KIND` aus constants
59
+ 3. Erstes Plugin im Pool das das kind bedient
60
+ 4. `RendererError("no_plugin_for_kind")` wenn nichts passt
61
+
62
+ ## Eigenes Plugin schreiben
63
+
64
+ ```typescript
65
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
66
+
67
+ export const myRendererFeature = defineFeature("renderer-myown", (r) => {
68
+ r.requires("renderer-foundation");
69
+ r.useExtension("renderer", "myown", {
70
+ kinds: ["document-pdf"],
71
+ // ctx ist optional — nur nehmen wenn Service-Access nötig
72
+ render: async (req, ctx) => {
73
+ // ctx.db, ctx.registry, ctx.tenantId verfügbar
74
+ // eigene PDF-Logik
75
+ return { kind: "document-pdf", pdfBytes: ..., pageCount: 1, sizeBytes: ... };
76
+ },
77
+ });
78
+ });
79
+ ```
80
+
81
+ ## Out-of-Scope
82
+
83
+ - Template-Storage (kommt aus `template-resolver`)
84
+ - Resource-URL-Substitution (Caller-Verantwortung: signed-URL vs. data-URI je nach kind)
85
+ - Template-Authoring-UI — `designer`-Bundle (geplant)
86
+ - Mail-Versand — `delivery` + `mail-transport-smtp`
@@ -0,0 +1,188 @@
1
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { describe, expect, test } from "vitest";
3
+ import { createRendererFoundationApi } from "../api";
4
+ import {
5
+ type RendererContext,
6
+ RendererError,
7
+ type RendererPlugin,
8
+ type RenderRequest,
9
+ type RenderResponse,
10
+ } from "../types";
11
+
12
+ // Stub-Context für Plugin-Render-Calls in Unit-Tests. makePlugin ignoriert
13
+ // ctx; db+registry sind hier null-cast weil Unit-Tests keinen echten
14
+ // Stack haben — Integration-Tests (collect-plugins.integration.ts) nutzen
15
+ // echte stack.db / stack.registry. tenantId ist valid UUID (Memory-Lesson
16
+ // feedback_system_tenant_id_is_uuid).
17
+ const STUB_CTX: RendererContext = {
18
+ db: null as never,
19
+ registry: null as never,
20
+ tenantId: "11111111-1111-4111-8111-111111111111" as TenantId,
21
+ };
22
+
23
+ // Test-Helper: minimal Plugin mit fix-Response. Mehrere im Pool für
24
+ // Multi-Kind- + Tenant-Override-Tests.
25
+ function makePlugin(name: string, kinds: RendererPlugin["kinds"]): RendererPlugin {
26
+ return {
27
+ name,
28
+ kinds,
29
+ render: async (req: RenderRequest): Promise<RenderResponse> => {
30
+ // shape-by-kind, gibt name in der response damit Tests sehen welcher plugin lief
31
+ switch (req.kind) {
32
+ case "notification":
33
+ return { kind: "notification", html: `from:${name}` };
34
+ case "mail-html":
35
+ return { kind: "mail-html", html: `from:${name}`, text: `from:${name}` };
36
+ case "document-pdf":
37
+ return {
38
+ kind: "document-pdf",
39
+ pdfBytes: new Uint8Array([1, 2, 3]),
40
+ pageCount: 1,
41
+ sizeBytes: 3,
42
+ };
43
+ case "image-snapshot":
44
+ return {
45
+ kind: "image-snapshot",
46
+ imageBytes: new Uint8Array([1]),
47
+ format: "png",
48
+ width: 1,
49
+ height: 1,
50
+ };
51
+ }
52
+ },
53
+ };
54
+ }
55
+
56
+ const TENANT: TenantId = "22222222-2222-4222-8222-222222222222" as TenantId;
57
+
58
+ describe("renderer-foundation :: Plugin-Selection", () => {
59
+ test("default-plugin für notification = 'simple'", async () => {
60
+ const api = createRendererFoundationApi([
61
+ makePlugin("simple", ["notification"]),
62
+ makePlugin("other", ["notification"]),
63
+ ]);
64
+ const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
65
+ expect(plugin.name).toBe("simple");
66
+ });
67
+
68
+ test("default-plugin für mail-html = 'mail-html'", async () => {
69
+ const api = createRendererFoundationApi([
70
+ makePlugin("simple", ["notification", "mail-html"]),
71
+ makePlugin("mail-html", ["mail-html"]),
72
+ ]);
73
+ const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "mail-html" });
74
+ expect(plugin.name).toBe("mail-html");
75
+ });
76
+
77
+ test("default-plugin für document-pdf = 'puppeteer'", async () => {
78
+ const api = createRendererFoundationApi([
79
+ makePlugin("puppeteer", ["document-pdf", "image-snapshot"]),
80
+ ]);
81
+ const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "document-pdf" });
82
+ expect(plugin.name).toBe("puppeteer");
83
+ });
84
+
85
+ test("Tenant-Override gewinnt vor Default", async () => {
86
+ const api = createRendererFoundationApi(
87
+ [makePlugin("simple", ["notification"]), makePlugin("custom-notif", ["notification"])],
88
+ (tid) => (tid === TENANT ? { notification: "custom-notif" } : null),
89
+ );
90
+ const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
91
+ expect(plugin.name).toBe("custom-notif");
92
+ });
93
+
94
+ test("Tenant-Override auf nicht-registriertes Plugin → fällt durch auf Default", async () => {
95
+ const api = createRendererFoundationApi([makePlugin("simple", ["notification"])], () => ({
96
+ notification: "ghost-plugin",
97
+ }));
98
+ const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
99
+ expect(plugin.name).toBe("simple");
100
+ });
101
+
102
+ test("Fallback auf erstes passendes Plugin wenn weder Tenant-Config noch Default-Name matchen", async () => {
103
+ const api = createRendererFoundationApi([makePlugin("nonstandard-name", ["notification"])]);
104
+ const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
105
+ expect(plugin.name).toBe("nonstandard-name");
106
+ });
107
+
108
+ test("kein Plugin für kind → RendererError", () => {
109
+ const api = createRendererFoundationApi([makePlugin("simple", ["notification"])]);
110
+ expect(() => api.createRendererForTenant({ tenantId: TENANT, kind: "document-pdf" })).toThrow(
111
+ RendererError,
112
+ );
113
+ });
114
+
115
+ test("leerer Plugin-Pool → RendererError für jeden kind", () => {
116
+ const api = createRendererFoundationApi([]);
117
+ expect(() => api.createRendererForTenant({ tenantId: TENANT, kind: "notification" })).toThrow(
118
+ RendererError,
119
+ );
120
+ expect(() => api.createRendererForTenant({ tenantId: TENANT, kind: "mail-html" })).toThrow(
121
+ RendererError,
122
+ );
123
+ expect(() => api.createRendererForTenant({ tenantId: TENANT, kind: "document-pdf" })).toThrow(
124
+ RendererError,
125
+ );
126
+ expect(() => api.createRendererForTenant({ tenantId: TENANT, kind: "image-snapshot" })).toThrow(
127
+ RendererError,
128
+ );
129
+ });
130
+
131
+ test("RendererError code='no_plugin_for_kind' wenn kein Plugin", () => {
132
+ const api = createRendererFoundationApi([]);
133
+ try {
134
+ api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
135
+ expect.fail("expected RendererError");
136
+ } catch (e) {
137
+ expect(e).toBeInstanceOf(RendererError);
138
+ expect((e as RendererError).code).toBe("no_plugin_for_kind");
139
+ }
140
+ });
141
+
142
+ test("Plugin mit mehreren kinds wird für jeden passend gewählt", async () => {
143
+ const multi = makePlugin("multi", ["notification", "mail-html", "document-pdf"]);
144
+ const api = createRendererFoundationApi([multi]);
145
+ expect(api.createRendererForTenant({ tenantId: TENANT, kind: "notification" }).name).toBe(
146
+ "multi",
147
+ );
148
+ expect(api.createRendererForTenant({ tenantId: TENANT, kind: "mail-html" }).name).toBe("multi");
149
+ expect(api.createRendererForTenant({ tenantId: TENANT, kind: "document-pdf" }).name).toBe(
150
+ "multi",
151
+ );
152
+ });
153
+
154
+ test("Plugin mit leeren kinds wird nie ausgewählt", () => {
155
+ const api = createRendererFoundationApi([
156
+ makePlugin("empty-kinds", []),
157
+ makePlugin("simple", ["notification"]),
158
+ ]);
159
+ const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
160
+ expect(plugin.name).toBe("simple");
161
+ });
162
+
163
+ test("Tenant-Override mit falschem kind-Plugin → ignoriert, Fallback", async () => {
164
+ // Tenant config sagt "puppeteer" für notification, aber puppeteer kann
165
+ // nur document-pdf. Foundation muss den falsche-kind-Eintrag ignorieren.
166
+ const api = createRendererFoundationApi(
167
+ [makePlugin("simple", ["notification"]), makePlugin("puppeteer", ["document-pdf"])],
168
+ () => ({ notification: "puppeteer" }),
169
+ );
170
+ const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
171
+ expect(plugin.name).toBe("simple");
172
+ });
173
+ });
174
+
175
+ describe("renderer-foundation :: Plugin executes render", () => {
176
+ test("Plugin.render returnt RenderResponse mit gleichem kind", async () => {
177
+ const api = createRendererFoundationApi([makePlugin("simple", ["notification"])]);
178
+ const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
179
+ const response = await plugin.render(
180
+ { kind: "notification", payload: { content: "hello", contentFormat: "markdown" } },
181
+ STUB_CTX,
182
+ );
183
+ expect(response.kind).toBe("notification");
184
+ if (response.kind === "notification") {
185
+ expect(response.html).toBe("from:simple");
186
+ }
187
+ });
188
+ });
@@ -0,0 +1,101 @@
1
+ import { defineFeature, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
3
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
4
+ import { createTemplateResolverFeature } from "../../template-resolver/feature";
5
+ import { createRendererFoundationApi } from "../api";
6
+ import { collectRendererPlugins, createRendererFoundationFeature } from "../feature";
7
+ import type { RenderRequest, RenderResponse } from "../types";
8
+
9
+ const TEST_TENANT = "11111111-1111-4111-8111-111111111111" as TenantId;
10
+
11
+ let stack: TestStack;
12
+
13
+ // Mini-Plugin via defineFeature + r.useExtension — wie ein echter
14
+ // renderer-plugin (renderer-simple, renderer-mail-html) sich registriert.
15
+ function createTestPluginFeature(name: string, kinds: ReadonlyArray<string>) {
16
+ return defineFeature(`renderer-${name}`, (r) => {
17
+ r.requires("renderer-foundation");
18
+ r.useExtension("renderer", name, {
19
+ kinds,
20
+ render: async (req: RenderRequest): Promise<RenderResponse> => {
21
+ if (req.kind === "notification") return { kind: "notification", html: `via:${name}` };
22
+ if (req.kind === "mail-html")
23
+ return { kind: "mail-html", html: `via:${name}`, text: `via:${name}` };
24
+ if (req.kind === "document-pdf")
25
+ return {
26
+ kind: "document-pdf",
27
+ pdfBytes: new Uint8Array([1]),
28
+ pageCount: 1,
29
+ sizeBytes: 1,
30
+ };
31
+ return {
32
+ kind: "image-snapshot",
33
+ imageBytes: new Uint8Array([1]),
34
+ format: "png",
35
+ width: 1,
36
+ height: 1,
37
+ };
38
+ },
39
+ });
40
+ });
41
+ }
42
+
43
+ beforeAll(async () => {
44
+ stack = await setupTestStack({
45
+ features: [
46
+ createTemplateResolverFeature(),
47
+ createRendererFoundationFeature(),
48
+ createTestPluginFeature("simple", ["notification"]),
49
+ createTestPluginFeature("mail-html", ["mail-html"]),
50
+ createTestPluginFeature("puppeteer", ["document-pdf", "image-snapshot"]),
51
+ ],
52
+ });
53
+ });
54
+
55
+ afterAll(async () => {
56
+ await stack.cleanup();
57
+ });
58
+
59
+ describe("renderer-foundation :: Plugin-Pool aus Registry", () => {
60
+ test("collectRendererPlugins findet alle registrierten Plugins", () => {
61
+ const plugins = collectRendererPlugins(stack.registry);
62
+ const names = plugins.map((p) => p.name).sort();
63
+ expect(names).toEqual(["mail-html", "puppeteer", "simple"]);
64
+ });
65
+
66
+ test("jeder Plugin behält seine kinds-Deklaration", () => {
67
+ const plugins = collectRendererPlugins(stack.registry);
68
+ const byName = new Map(plugins.map((p) => [p.name, p]));
69
+ expect([...byName.get("simple")!.kinds]).toEqual(["notification"]);
70
+ expect([...byName.get("mail-html")!.kinds]).toEqual(["mail-html"]);
71
+ expect([...byName.get("puppeteer")!.kinds].sort()).toEqual(["document-pdf", "image-snapshot"]);
72
+ });
73
+
74
+ test("API findet Default-Plugin pro kind aus echtem Pool", () => {
75
+ const plugins = collectRendererPlugins(stack.registry);
76
+ const api = createRendererFoundationApi(plugins);
77
+ expect(api.createRendererForTenant({ tenantId: TEST_TENANT, kind: "notification" }).name).toBe(
78
+ "simple",
79
+ );
80
+ expect(api.createRendererForTenant({ tenantId: TEST_TENANT, kind: "mail-html" }).name).toBe(
81
+ "mail-html",
82
+ );
83
+ expect(api.createRendererForTenant({ tenantId: TEST_TENANT, kind: "document-pdf" }).name).toBe(
84
+ "puppeteer",
85
+ );
86
+ });
87
+
88
+ test("Plugin.render mit echtem Pool fließt end-to-end durch", async () => {
89
+ const plugins = collectRendererPlugins(stack.registry);
90
+ const api = createRendererFoundationApi(plugins);
91
+ const plugin = api.createRendererForTenant({ tenantId: TEST_TENANT, kind: "notification" });
92
+ const result = await plugin.render(
93
+ { kind: "notification", payload: { content: "hello", contentFormat: "markdown" } },
94
+ { db: stack.db, registry: stack.registry, tenantId: TEST_TENANT },
95
+ );
96
+ expect(result.kind).toBe("notification");
97
+ if (result.kind === "notification") {
98
+ expect(result.html).toBe("via:simple");
99
+ }
100
+ });
101
+ });
@@ -0,0 +1,106 @@
1
+ // RendererFoundationApi — Cross-Feature-Schnittstelle. Konsumenten
2
+ // (delivery, mail-Sender, Solon NKA-PDF-Generator) holen sich pro
3
+ // Render-Call den passenden Plugin via `createRendererForTenant`.
4
+ // Pattern symmetrisch zu ai-foundation's `createLLMProviderForTenant`.
5
+
6
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
8
+ import { DEFAULT_PLUGIN_BY_KIND, type RenderKind } from "./constants";
9
+ import { RendererError, type RendererPlugin } from "./types";
10
+
11
+ export type RendererFoundationApi = {
12
+ /**
13
+ * Wählt + returnt ein RendererPlugin für (tenantId × kind). Caller
14
+ * rufen anschließend `plugin.render(req, ctx)`.
15
+ *
16
+ * **Auswahl-Reihenfolge:**
17
+ * 1. Tenant-spezifische Config (config-Bundle: `rendererPluginByKind`)
18
+ * 2. DEFAULT_PLUGIN_BY_KIND aus constants
19
+ * 3. Erstes Plugin im Pool das das kind bedient
20
+ * 4. `RendererError("no_plugin_for_kind")` wenn nichts passt
21
+ *
22
+ * **Caller-Pattern:** `tenantId` MUSS vom Server kommen (typisch
23
+ * `ctx.user.tenantId`). Plugins, die Service-Access brauchen (z.B.
24
+ * `renderer-mail-html` für Layout-Resolve via `template-resolver`),
25
+ * erhalten `RendererContext` als zweites Argument zu `render()` —
26
+ * matcht das Pattern von `DeliveryChannel.send(addr, msg, ctx)`.
27
+ * Plugins ohne Service-Deps (z.B. `renderer-simple`) ignorieren ctx.
28
+ */
29
+ readonly createRendererForTenant: (args: {
30
+ readonly tenantId: TenantId;
31
+ readonly kind: RenderKind;
32
+ }) => RendererPlugin;
33
+ };
34
+
35
+ // Plugin-Pool wird zur Boot-Zeit aus Extension-Usages aufgebaut.
36
+ // Aus dem Registry kommen alle registrierten Plugins. Foundation-Feature
37
+ // baut den Pool via `collectRendererPlugins(registry)` und steckt das
38
+ // Result in extraContext.
39
+ export function createRendererFoundationApi(
40
+ plugins: ReadonlyArray<RendererPlugin>,
41
+ tenantConfigLookup: (tenantId: TenantId) => Record<string, string> | null = () => null,
42
+ ): RendererFoundationApi {
43
+ const byKind = indexByKind(plugins);
44
+ return {
45
+ createRendererForTenant: ({ tenantId, kind }) => {
46
+ const tenantConfig = tenantConfigLookup(tenantId) ?? {};
47
+
48
+ // 1. Tenant-Override
49
+ const tenantPluginName = tenantConfig[kind];
50
+ if (tenantPluginName) {
51
+ const plugin = plugins.find((p) => p.name === tenantPluginName && p.kinds.includes(kind));
52
+ if (plugin) return plugin;
53
+ // Tenant-Config zeigt auf nicht-registriertes Plugin — fällt durch zu Default
54
+ }
55
+
56
+ // 2. Default-Plugin
57
+ const defaultName = DEFAULT_PLUGIN_BY_KIND[kind];
58
+ if (defaultName) {
59
+ const plugin = plugins.find((p) => p.name === defaultName && p.kinds.includes(kind));
60
+ if (plugin) return plugin;
61
+ }
62
+
63
+ // 3. Erstes Plugin im Pool das das kind bedient
64
+ const first = byKind.get(kind)?.[0];
65
+ if (first) return first;
66
+
67
+ // 4. Kein Plugin → Error
68
+ throw new RendererError(
69
+ `[renderer-foundation] no plugin registered for kind="${kind}". Mount at least one plugin (renderer-simple, renderer-mail-html, renderer-puppeteer-client) for this kind.`,
70
+ "no_plugin_for_kind",
71
+ );
72
+ },
73
+ };
74
+ }
75
+
76
+ function indexByKind(plugins: ReadonlyArray<RendererPlugin>): Map<RenderKind, RendererPlugin[]> {
77
+ const map = new Map<RenderKind, RendererPlugin[]>();
78
+ for (const plugin of plugins) {
79
+ for (const kind of plugin.kinds) {
80
+ const list = map.get(kind) ?? [];
81
+ list.push(plugin);
82
+ map.set(kind, list);
83
+ }
84
+ }
85
+ return map;
86
+ }
87
+
88
+ // Single point of truth für "dieser Handler braucht renderer-foundation".
89
+ // Pattern symmetrisch zu requireTemplateResolver + requireTextContent.
90
+ export function requireRendererFoundation(
91
+ ctx: { readonly rendererFoundation?: RendererFoundationApi } | object,
92
+ callerName: string,
93
+ ): RendererFoundationApi {
94
+ // @cast-boundary engine-bridge — rendererFoundation kommt per extraContext
95
+ // aus App-Bootstrap.
96
+ const api = (ctx as { rendererFoundation?: RendererFoundationApi }).rendererFoundation;
97
+ if (!api) {
98
+ throw new InternalError({
99
+ message:
100
+ `[${callerName}] ctx.rendererFoundation missing — App-Bootstrap muss ` +
101
+ `extraContext: { rendererFoundation: createRendererFoundationApi(plugins) } setzen ` +
102
+ `(siehe renderer-foundation/README.md).`,
103
+ });
104
+ }
105
+ return api;
106
+ }
@@ -0,0 +1,21 @@
1
+ // Re-export aus template-resolver — gleiche RenderKind-Domäne. Beide
2
+ // Bundles teilen sich die Enum, damit Plugins (renderer-mail-html etc.)
3
+ // nicht zwei Quellen importieren müssen. Cross-feature-Imports gehen
4
+ // über das Barrel (../template-resolver), nicht via deep-import.
5
+ import type { RenderKind as RenderKindLocal } from "../template-resolver";
6
+
7
+ export {
8
+ CONTENT_FORMATS,
9
+ type ContentFormat,
10
+ RENDER_KINDS,
11
+ type RenderKind,
12
+ } from "../template-resolver";
13
+
14
+ // Standard-Default-Plugin pro Kind, wenn Tenant keine explizite Config
15
+ // gesetzt hat. App-Bootstrap kann das via TenantConfigKey überschreiben.
16
+ export const DEFAULT_PLUGIN_BY_KIND: Readonly<Record<RenderKindLocal, string>> = {
17
+ notification: "simple",
18
+ "mail-html": "mail-html",
19
+ "document-pdf": "puppeteer",
20
+ "image-snapshot": "puppeteer",
21
+ };
@@ -0,0 +1,47 @@
1
+ import { defineFeature, type Registry } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { RendererPlugin } from "./types";
3
+
4
+ // renderer-foundation — Plugin-Foundation für Renderer (Notification,
5
+ // HTML-Mail, PDF, Image). Plan-Doc:
6
+ // kumiko-platform/docs/plans/features/renderer-foundation.md
7
+ //
8
+ // Pattern symmetrisch zu ai-foundation: Foundation definiert den
9
+ // Extension-Point `renderer`, Plugins (renderer-simple, renderer-mail-html,
10
+ // renderer-puppeteer-client) registrieren sich via `r.useExtension`.
11
+ // Konsumenten holen sich Plugin runtime via createRendererForTenant.
12
+ export function createRendererFoundationFeature() {
13
+ return defineFeature("renderer-foundation", (r) => {
14
+ r.requires("template-resolver");
15
+
16
+ r.extendsRegistrar("renderer", {
17
+ onRegister: () => {
18
+ // Plugin-Konformitäts-Check könnte hier: shape-validation der
19
+ // options (kinds, render-Funktion present). Aktuell kein
20
+ // shape-check — Caller's TypeScript-Type-Sicherheit greift.
21
+ },
22
+ });
23
+
24
+ return {};
25
+ });
26
+ }
27
+
28
+ // Plugin-Pool-Aufbau zur Boot-Zeit. App-Bootstrap ruft das nach
29
+ // Feature-Registration auf, baut den Pool, gibt ihn via extraContext
30
+ // an die Handlers weiter.
31
+ //
32
+ // Symmetrisch zu collectChannels / collectRenderers in delivery-service.
33
+ export function collectRendererPlugins(registry: Registry): RendererPlugin[] {
34
+ const usages = registry.getExtensionUsages("renderer");
35
+ return usages.map((usage) => {
36
+ // @cast-boundary engine-payload — extension-usage carries unknown options
37
+ const opts = usage.options as {
38
+ kinds: RendererPlugin["kinds"];
39
+ render: RendererPlugin["render"];
40
+ };
41
+ return {
42
+ name: usage.entityName,
43
+ kinds: opts.kinds,
44
+ render: opts.render,
45
+ };
46
+ });
47
+ }
@@ -0,0 +1,25 @@
1
+ export {
2
+ createRendererFoundationApi,
3
+ type RendererFoundationApi,
4
+ requireRendererFoundation,
5
+ } from "./api";
6
+ export {
7
+ CONTENT_FORMATS,
8
+ type ContentFormat,
9
+ DEFAULT_PLUGIN_BY_KIND,
10
+ RENDER_KINDS,
11
+ type RenderKind,
12
+ } from "./constants";
13
+ export { collectRendererPlugins, createRendererFoundationFeature } from "./feature";
14
+ export {
15
+ type DocumentPayload,
16
+ type ImageOptions,
17
+ type MailHtmlPayload,
18
+ type NotificationPayload,
19
+ type PdfOptions,
20
+ type RendererContext,
21
+ RendererError,
22
+ type RendererPlugin,
23
+ type RenderRequest,
24
+ type RenderResponse,
25
+ } from "./types";