@cosmicdrift/kumiko-bundled-features 0.102.2 → 0.105.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 (23) hide show
  1. package/package.json +6 -6
  2. package/src/auth-email-password/__tests__/email-templates.test.ts +44 -47
  3. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +41 -21
  4. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +62 -32
  5. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +39 -20
  6. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +39 -8
  7. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +53 -36
  8. package/src/auth-email-password/auth-paths.ts +24 -0
  9. package/src/auth-email-password/email-templates.ts +59 -118
  10. package/src/auth-email-password/feature.ts +19 -1
  11. package/src/auth-email-password/handlers/invite-create.write.ts +33 -5
  12. package/src/auth-email-password/handlers/request-email-verification.write.ts +5 -0
  13. package/src/auth-email-password/handlers/request-password-reset.write.ts +5 -0
  14. package/src/auth-email-password/handlers/signup-request.write.ts +38 -8
  15. package/src/auth-email-password/handlers/token-request-handler.ts +31 -0
  16. package/src/auth-email-password/index.ts +9 -20
  17. package/src/auth-email-password/magic-link-mail.ts +62 -0
  18. package/src/renderer-simple/__tests__/adapter.test.ts +38 -19
  19. package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +102 -0
  20. package/src/renderer-simple/feature.ts +14 -3
  21. package/src/renderer-simple/resolve-variables.ts +55 -0
  22. package/src/auth-email-password/__tests__/auth-mailer.test.ts +0 -138
  23. package/src/auth-email-password/auth-mailer.ts +0 -153
@@ -1,8 +1,10 @@
1
1
  // Tenant-Invite Step 1 (create).
2
2
  //
3
3
  // Admin invitet email → DB-Row entsteht via event-store-executor (oder
4
- // wird re-used bei Re-Invite), Random-Token in Redis bidirektional,
5
- // Route-Layer schickt Mail mit Activation-URL.
4
+ // wird re-used bei Re-Invite), Random-Token in Redis bidirektional, und der
5
+ // Handler schickt die Invite-Mail an den Invitee via delivery (ctx.notify) —
6
+ // wie reset/verify/signup. Der Token geht NICHT an den Admin zurück (er soll
7
+ // die Annahme nicht impersonieren können).
6
8
  //
7
9
  // Resend-Idempotenz: Re-Invite für gleiche (tenantId, email) während
8
10
  // pending → existing row + token re-genutzt + TTL refresh + zweite Mail
@@ -33,7 +35,12 @@ import {
33
35
  reservedMembershipRoleError,
34
36
  } from "../../tenant/membership-roles";
35
37
  import { AUTH_INVITE_DEFAULT_TTL_MINUTES } from "../constants";
38
+ import type { AuthMailLocale } from "../email-templates";
39
+ import { renderInviteEmail } from "../email-templates";
36
40
  import { getTokenForInvitation, storeInviteToken } from "../invite-token-store";
41
+ import { dispatchMagicLinkMail } from "../magic-link-mail";
42
+
43
+ const INVITE_NOTIFICATION_TYPE = "auth-email-password:invite";
37
44
 
38
45
  const InviteCreateSchema = z.object({
39
46
  email: z.email(),
@@ -46,20 +53,24 @@ export type InviteCreateData = {
46
53
  readonly tenantId: string;
47
54
  readonly email: string;
48
55
  readonly role: string;
49
- readonly token: string;
50
56
  readonly expiresAt: string;
51
57
  };
52
58
 
53
59
  export type InviteCreateOptions = {
54
60
  /** TTL für den Activation-Token. Default 7 Tage. */
55
61
  readonly tokenTtlMinutes?: number;
62
+ /** App page that receives the magic-link; the handler appends `?token=…`
63
+ * and dispatches the invite mail via delivery (ctx.notify). */
64
+ readonly appUrl: string;
65
+ readonly appName?: string;
66
+ readonly locale?: AuthMailLocale;
56
67
  };
57
68
 
58
69
  const executor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEntity, {
59
70
  entityName: "tenant-invitation",
60
71
  });
61
72
 
62
- export function createInviteCreateHandler(opts: InviteCreateOptions = {}) {
73
+ export function createInviteCreateHandler(opts: InviteCreateOptions) {
63
74
  const ttlMinutes = opts.tokenTtlMinutes ?? AUTH_INVITE_DEFAULT_TTL_MINUTES;
64
75
  const ttlSeconds = ttlMinutes * 60;
65
76
 
@@ -133,6 +144,24 @@ export function createInviteCreateHandler(opts: InviteCreateOptions = {}) {
133
144
 
134
145
  await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
135
146
 
147
+ await dispatchMagicLinkMail(
148
+ ctx.notify,
149
+ {
150
+ handlerName: "invite-create",
151
+ notificationType: INVITE_NOTIFICATION_TYPE,
152
+ renderContent: (renderArgs) =>
153
+ renderInviteEmail({ ...renderArgs, role: event.payload.role }),
154
+ },
155
+ {
156
+ email,
157
+ appUrl: opts.appUrl,
158
+ token,
159
+ expiresAt: expiresAt.toString(),
160
+ ...(opts.appName !== undefined && { appName: opts.appName }),
161
+ ...(opts.locale !== undefined && { locale: opts.locale }),
162
+ },
163
+ );
164
+
136
165
  return {
137
166
  isSuccess: true,
138
167
  data: {
@@ -141,7 +170,6 @@ export function createInviteCreateHandler(opts: InviteCreateOptions = {}) {
141
170
  tenantId,
142
171
  email,
143
172
  role: event.payload.role,
144
- token,
145
173
  expiresAt: expiresAt.toString(),
146
174
  },
147
175
  };
@@ -1,4 +1,5 @@
1
1
  import { AUTH_VERIFY_DEFAULT_TTL_MINUTES, AuthErrors } from "../constants";
2
+ import { renderVerifyEmail } from "../email-templates";
2
3
  import { signVerificationToken } from "../verification-token";
3
4
  import {
4
5
  createTokenRequestHandler,
@@ -6,6 +7,8 @@ import {
6
7
  type TokenRequestOptions,
7
8
  } from "./token-request-handler";
8
9
 
10
+ const VERIFY_NOTIFICATION_TYPE = "auth-email-password:email-verification";
11
+
9
12
  export type RequestEmailVerificationOptions = TokenRequestOptions;
10
13
 
11
14
  export type RequestVerificationData = TokenRequestData<"verification-requested">;
@@ -24,6 +27,8 @@ export function createRequestEmailVerificationHandler(opts: RequestEmailVerifica
24
27
  // unknown/deleted to keep the enumeration surface symmetric — the
25
28
  // caller sees the same 200 regardless of whether a token was minted.
26
29
  extraSilentSkip: (user) => user.emailVerified === true,
30
+ notificationType: VERIFY_NOTIFICATION_TYPE,
31
+ renderContent: renderVerifyEmail,
27
32
  },
28
33
  opts,
29
34
  );
@@ -1,4 +1,5 @@
1
1
  import { AUTH_RESET_DEFAULT_TTL_MINUTES, AuthErrors } from "../constants";
2
+ import { renderResetPasswordEmail } from "../email-templates";
2
3
  import { signResetToken } from "../reset-token";
3
4
  import {
4
5
  createTokenRequestHandler,
@@ -6,6 +7,8 @@ import {
6
7
  type TokenRequestOptions,
7
8
  } from "./token-request-handler";
8
9
 
10
+ const RESET_NOTIFICATION_TYPE = "auth-email-password:password-reset";
11
+
9
12
  export type RequestPasswordResetOptions = TokenRequestOptions;
10
13
 
11
14
  // Public shape re-exported for callers that build custom routes on top of
@@ -26,6 +29,8 @@ export function createRequestPasswordResetHandler(opts: RequestPasswordResetOpti
26
29
  // non-deleted user can initiate a reset regardless of verification
27
30
  // state. The sessions feature handles the post-change revocation.
28
31
  extraSilentSkip: () => false,
32
+ notificationType: RESET_NOTIFICATION_TYPE,
33
+ renderContent: renderResetPasswordEmail,
29
34
  },
30
35
  opts,
31
36
  );
@@ -1,8 +1,8 @@
1
1
  // Magic-Link-Signup, Step 1 (request).
2
2
  //
3
3
  // User gibt Email ein → wir minten einen opaken Random-Token, speichern
4
- // ihn bidirektional in Redis (token↔email), und der Route-Layer schickt
5
- // die Activation-Mail. Anders als reset/verify-Flows machen wir HIER keinen
4
+ // ihn bidirektional in Redis (token↔email), und schicken die Activation-Mail
5
+ // via delivery (ctx.notify) wie reset/verify. Anders als die: HIER kein
6
6
  // userId-Lookup und kein HMAC-signing (es gäbe kein Subject — im Normalfall
7
7
  // existiert der User noch nicht). Ob die Email bereits ein Konto hat,
8
8
  // entscheidet bewusst der Confirm-Schritt, nicht dieser.
@@ -28,8 +28,13 @@ import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/error
28
28
  import { Temporal } from "temporal-polyfill";
29
29
  import { z } from "zod";
30
30
  import { AUTH_SIGNUP_DEFAULT_TTL_MINUTES } from "../constants";
31
+ import type { AuthMailLocale } from "../email-templates";
32
+ import { renderActivationEmail } from "../email-templates";
33
+ import { dispatchMagicLinkMail } from "../magic-link-mail";
31
34
  import { getTokenForSignupEmail, normalizeEmail, storeSignupToken } from "../signup-token-store";
32
35
 
36
+ const SIGNUP_NOTIFICATION_TYPE = "auth-email-password:signup-activation";
37
+
33
38
  const SignupRequestSchema = z.object({
34
39
  email: z.email(),
35
40
  });
@@ -47,9 +52,14 @@ export type SignupRequestOptions = {
47
52
  /** TTL für den Activation-Token. Default 24 h — lang genug damit User
48
53
  * "morgen aktivieren" können ohne Resend-Spam. */
49
54
  readonly tokenTtlMinutes?: number;
55
+ /** App page that receives the magic-link; the handler appends `?token=…`
56
+ * and dispatches the activation mail via delivery (ctx.notify). */
57
+ readonly appUrl: string;
58
+ readonly appName?: string;
59
+ readonly locale?: AuthMailLocale;
50
60
  };
51
61
 
52
- export function createSignupRequestHandler(opts: SignupRequestOptions = {}) {
62
+ export function createSignupRequestHandler(opts: SignupRequestOptions) {
53
63
  const ttlMinutes = opts.tokenTtlMinutes ?? AUTH_SIGNUP_DEFAULT_TTL_MINUTES;
54
64
  const ttlSeconds = ttlMinutes * 60;
55
65
 
@@ -86,19 +96,39 @@ export function createSignupRequestHandler(opts: SignupRequestOptions = {}) {
86
96
  const token = existingToken ?? generateToken();
87
97
 
88
98
  const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
99
+ const expiresAtIso = expiresAt.toString();
89
100
 
90
101
  await storeSignupToken(ctx.redis, { email, token, ttlSeconds });
91
102
 
103
+ // normalizeEmail aus dem Store — eine Quelle für die Normalisierungs-
104
+ // Verantwortung; delivery-Empfänger + Lookup-Pfad kriegen konsistent
105
+ // das gleiche Format.
106
+ const normalizedEmail = normalizeEmail(email);
107
+
108
+ await dispatchMagicLinkMail(
109
+ ctx.notify,
110
+ {
111
+ handlerName: "signup-request",
112
+ notificationType: SIGNUP_NOTIFICATION_TYPE,
113
+ renderContent: renderActivationEmail,
114
+ },
115
+ {
116
+ email: normalizedEmail,
117
+ appUrl: opts.appUrl,
118
+ token,
119
+ expiresAt: expiresAtIso,
120
+ ...(opts.appName !== undefined && { appName: opts.appName }),
121
+ ...(opts.locale !== undefined && { locale: opts.locale }),
122
+ },
123
+ );
124
+
92
125
  return {
93
126
  isSuccess: true,
94
127
  data: {
95
128
  kind: "signup-requested",
96
- // normalizeEmail aus dem Store — eine Quelle für die
97
- // Normalisierungs-Verantwortung; Mail-Callback kriegt
98
- // konsistent das gleiche Format wie der Lookup-Pfad.
99
- email: normalizeEmail(email),
129
+ email: normalizedEmail,
100
130
  token,
101
- expiresAt: expiresAt.toString(),
131
+ expiresAt: expiresAtIso,
102
132
  },
103
133
  };
104
134
  },
@@ -17,6 +17,8 @@ import type { Temporal } from "temporal-polyfill";
17
17
  import { z } from "zod";
18
18
  import { UserQueries } from "../../user";
19
19
  import { type AuthUserRow, parseAuthUserRow } from "../auth-user-row";
20
+ import type { AuthMailContent, AuthMailLocale, RenderTokenContentArgs } from "../email-templates";
21
+ import { dispatchMagicLinkMail } from "../magic-link-mail";
20
22
 
21
23
  const RequestTokenSchema = z.object({
22
24
  email: z.email(),
@@ -56,11 +58,22 @@ export type TokenRequestSpec<TName extends string, TSuccessKind extends string>
56
58
  // soft-deleted". Verification skips when emailVerified is already true;
57
59
  // password-reset has no extra condition (returns false).
58
60
  readonly extraSilentSkip: (user: AuthUserRow) => boolean;
61
+ // Notification type dispatched via ctx.notify on success. No r.notification
62
+ // is declared (the recipient is an anonymous email, not a userId) — the
63
+ // route:{email} path delivers directly and buildMessage falls back to data.
64
+ readonly notificationType: string;
65
+ // Builds the structured mail body from the magic-link + expiry. Per-flow
66
+ // (renderResetPasswordEmail / renderVerifyEmail).
67
+ readonly renderContent: (args: RenderTokenContentArgs) => AuthMailContent;
59
68
  };
60
69
 
61
70
  export type TokenRequestOptions = {
62
71
  readonly hmacSecret: string;
63
72
  readonly tokenTtlMinutes?: number;
73
+ // App page that receives the magic-link; the handler appends `?token=…`.
74
+ readonly appUrl: string;
75
+ readonly appName?: string;
76
+ readonly locale?: AuthMailLocale;
64
77
  };
65
78
 
66
79
  export function createTokenRequestHandler<TName extends string, TSuccessKind extends string>(
@@ -103,6 +116,24 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
103
116
  }
104
117
 
105
118
  const { token, expiresAt } = spec.sign(user.id, ttl, opts.hmacSecret);
119
+
120
+ await dispatchMagicLinkMail(
121
+ ctx.notify,
122
+ {
123
+ handlerName: spec.handlerName,
124
+ notificationType: spec.notificationType,
125
+ renderContent: spec.renderContent,
126
+ },
127
+ {
128
+ email: user.email,
129
+ appUrl: opts.appUrl,
130
+ token,
131
+ expiresAt: expiresAt.toString(),
132
+ ...(opts.appName !== undefined && { appName: opts.appName }),
133
+ ...(opts.locale !== undefined && { locale: opts.locale }),
134
+ },
135
+ );
136
+
106
137
  const data: TokenRequestData<TSuccessKind> = {
107
138
  kind: spec.successKind,
108
139
  email: user.email,
@@ -1,27 +1,16 @@
1
- // Factory für app-spezifische Auth-Mail-Configs. Baut passwordReset,
2
- // emailVerification, signup und invite Setups gegen mailSender + Render-
3
- // Funktionen eliminiert Duplikate zwischen kumiko-studio, publicstatus
4
- // und solon (jede App hatte identische send*Email-Wrapper kopiert).
5
- export {
6
- type AuthMailerConfig,
7
- type AuthPaths,
8
- type CreateAuthMailerConfigArgs,
9
- createAuthMailerConfig,
10
- DEFAULT_AUTH_PATHS,
11
- makeAuthPaths,
12
- } from "./auth-mailer";
1
+ // Convention paths for the auth pages (reset/verify/signup/invite); apps build
2
+ // their magic-link appUrls from these. All mail now goes through delivery.
3
+ export { type AuthPaths, DEFAULT_AUTH_PATHS, makeAuthPaths } from "./auth-paths";
13
4
  export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers } from "./constants";
14
- // Default-HTML-Renderer für die Reset-Password + Verify-Email Mails.
15
- // Apps wiren die `sendResetEmail` / `sendVerificationEmail` callbacks
16
- // im framework-config — diese Renderer können als one-liner genutzt
17
- // werden, oder die App schreibt einen eigenen Renderer für Branding.
5
+ // Renderers for the auth mails. All four magic-link flows (reset, verify,
6
+ // signup-activation, invite) emit structured AuthMailContent through delivery
7
+ // (ctx.notify).
18
8
  export type {
9
+ AuthMailContent,
19
10
  AuthMailLocale,
20
- RenderActivationEmailArgs,
21
- RenderedEmail,
11
+ AuthMailSection,
22
12
  RenderInviteEmailArgs,
23
- RenderResetPasswordEmailArgs,
24
- RenderVerifyEmailArgs,
13
+ RenderTokenContentArgs,
25
14
  } from "./email-templates";
26
15
  export {
27
16
  renderActivationEmail,
@@ -0,0 +1,62 @@
1
+ // Shared dispatch for the magic-link mails (password-reset, email-verification,
2
+ // signup-activation). All three follow the same delivery shape: append the
3
+ // token to the app page, render structured content, and notify the recipient
4
+ // email directly (route:{email} skips preferences — the recipient may not have
5
+ // a user account yet — and critical priority keeps a security mail
6
+ // non-unsubscribable). No r.notification is declared; buildMessage falls back
7
+ // to the structured `data` for the renderer.
8
+
9
+ import type { NotifyFn } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
11
+ import type { AuthMailContent, AuthMailLocale, RenderTokenContentArgs } from "./email-templates";
12
+
13
+ // Per-flow constants: which notification type to dispatch and how to render the
14
+ // body. renderContent is the flow's template (renderResetPasswordEmail / … /
15
+ // renderActivationEmail), all unified on RenderTokenContentArgs → AuthMailContent.
16
+ export type MagicLinkMailSpec = {
17
+ readonly handlerName: string;
18
+ readonly notificationType: string;
19
+ readonly renderContent: (args: RenderTokenContentArgs) => AuthMailContent;
20
+ };
21
+
22
+ // Per-request values: the recipient + the app page that receives the token, plus
23
+ // optional presentation. appUrl is the bare page URL; the token is appended here.
24
+ export type MagicLinkMailParams = {
25
+ readonly email: string;
26
+ readonly appUrl: string;
27
+ readonly token: string;
28
+ readonly expiresAt: string;
29
+ readonly appName?: string;
30
+ readonly locale?: AuthMailLocale;
31
+ };
32
+
33
+ function appendToken(appUrl: string, token: string): string {
34
+ const sep = appUrl.includes("?") ? "&" : "?";
35
+ return `${appUrl}${sep}token=${encodeURIComponent(token)}`;
36
+ }
37
+
38
+ export async function dispatchMagicLinkMail(
39
+ notify: NotifyFn | undefined,
40
+ spec: MagicLinkMailSpec,
41
+ params: MagicLinkMailParams,
42
+ ): Promise<void> {
43
+ // delivery is a hard requirement when a magic-link flow is mounted (see
44
+ // feature.ts r.requires), so notify is always wired — this guard is a
45
+ // defensive boot-invariant, not a runtime branch.
46
+ if (!notify) {
47
+ throw new InternalError({
48
+ message: `${spec.handlerName}: ctx.notify unavailable — the delivery feature must be mounted`,
49
+ });
50
+ }
51
+ const content = spec.renderContent({
52
+ url: appendToken(params.appUrl, params.token),
53
+ expiresAt: params.expiresAt,
54
+ ...(params.locale !== undefined && { locale: params.locale }),
55
+ ...(params.appName !== undefined && { appName: params.appName }),
56
+ });
57
+ await notify(spec.notificationType, {
58
+ route: { email: params.email },
59
+ data: content,
60
+ priority: "critical",
61
+ });
62
+ }
@@ -1,16 +1,26 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { RendererError } from "../../renderer-foundation";
2
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { type RendererContext, RendererError } from "../../renderer-foundation";
3
4
  import { adaptToFoundation } from "../feature";
4
5
 
6
+ const STUB_CTX: RendererContext = {
7
+ db: null as never,
8
+ registry: null as never,
9
+ tenantId: "11111111-1111-4111-8111-111111111111" as TenantId,
10
+ };
11
+
5
12
  describe("renderer-simple :: adaptToFoundation", () => {
6
13
  test("kind='notification' rendert via simpleRenderer und gibt RenderResponse zurück", async () => {
7
- const res = await adaptToFoundation({
8
- kind: "notification",
9
- payload: {
10
- template: "welcome",
11
- variables: { title: "Welcome!", body: "Hello there" },
14
+ const res = await adaptToFoundation(
15
+ {
16
+ kind: "notification",
17
+ payload: {
18
+ template: "welcome",
19
+ variables: { title: "Welcome!", body: "Hello there" },
20
+ },
12
21
  },
13
- });
22
+ STUB_CTX,
23
+ );
14
24
  expect(res.kind).toBe("notification");
15
25
  if (res.kind === "notification") {
16
26
  // simpleRenderer baut HTML mit title als header + body als section
@@ -21,26 +31,35 @@ describe("renderer-simple :: adaptToFoundation", () => {
21
31
  });
22
32
 
23
33
  test("leere variables → leerer body, kein crash", async () => {
24
- const res = await adaptToFoundation({
25
- kind: "notification",
26
- payload: { template: "", variables: {} },
27
- });
34
+ const res = await adaptToFoundation(
35
+ {
36
+ kind: "notification",
37
+ payload: { template: "", variables: {} },
38
+ },
39
+ STUB_CTX,
40
+ );
28
41
  expect(res.kind).toBe("notification");
29
42
  });
30
43
 
31
44
  test("non-notification kind → RendererError mit code 'invalid_payload'", async () => {
32
45
  await expect(
33
- adaptToFoundation({
34
- kind: "mail-html",
35
- payload: { content: "test", contentFormat: "markdown" },
36
- }),
46
+ adaptToFoundation(
47
+ {
48
+ kind: "mail-html",
49
+ payload: { content: "test", contentFormat: "markdown" },
50
+ },
51
+ STUB_CTX,
52
+ ),
37
53
  ).rejects.toThrow(RendererError);
38
54
 
39
55
  try {
40
- await adaptToFoundation({
41
- kind: "document-pdf",
42
- payload: { content: "test", contentFormat: "markdown" },
43
- });
56
+ await adaptToFoundation(
57
+ {
58
+ kind: "document-pdf",
59
+ payload: { content: "test", contentFormat: "markdown" },
60
+ },
61
+ STUB_CTX,
62
+ );
44
63
  throw new Error("expected RendererError");
45
64
  } catch (e) {
46
65
  expect(e).toBeInstanceOf(RendererError);
@@ -0,0 +1,102 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
3
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
+ import {
6
+ setupTestStack,
7
+ type TestStack,
8
+ unsafeCreateEntityTable,
9
+ } from "@cosmicdrift/kumiko-framework/stack";
10
+ import type { RendererContext } from "../../renderer-foundation";
11
+ import { SYSTEM_TENANT_ID } from "../../template-resolver/constants";
12
+ import { createTemplateResolverFeature } from "../../template-resolver/feature";
13
+ import { templateResourceEntity, templateResourcesTable } from "../../template-resolver/table";
14
+ import { adaptToFoundation } from "../feature";
15
+
16
+ let stack: TestStack;
17
+ let db: DbConnection;
18
+
19
+ const TENANT_ID = "11111111-1111-4111-8111-111111111111" as TenantId;
20
+
21
+ const templateResolverFeature = createTemplateResolverFeature();
22
+
23
+ beforeAll(async () => {
24
+ stack = await setupTestStack({ features: [templateResolverFeature] });
25
+ db = stack.db;
26
+ await unsafeCreateEntityTable(db, templateResourceEntity);
27
+ });
28
+
29
+ afterAll(async () => {
30
+ await stack.cleanup();
31
+ });
32
+
33
+ function rendererCtx(): RendererContext {
34
+ return { db, registry: stack.registry, tenantId: TENANT_ID };
35
+ }
36
+
37
+ async function seedPlainNotificationTemplate(content: string): Promise<void> {
38
+ await insertOne(db, templateResourcesTable, {
39
+ tenantId: SYSTEM_TENANT_ID,
40
+ slug: "welcome-mail",
41
+ kind: "notification",
42
+ locale: "de",
43
+ scope: "system",
44
+ status: "active",
45
+ content,
46
+ contentFormat: "plain",
47
+ variableSchema: JSON.stringify({}),
48
+ linkedResources: JSON.stringify({}),
49
+ parentTemplateId: null,
50
+ createdBy: "test",
51
+ updatedBy: "test",
52
+ });
53
+ }
54
+
55
+ describe("renderer-simple :: template-resolver integration", () => {
56
+ test("resolves slug via template-resolver and merges runtime variables", async () => {
57
+ await seedPlainNotificationTemplate(
58
+ JSON.stringify({
59
+ header: "Template header",
60
+ sections: [{ text: "Template body" }],
61
+ }),
62
+ );
63
+
64
+ const res = await adaptToFoundation(
65
+ {
66
+ kind: "notification",
67
+ payload: {
68
+ template: "welcome-mail",
69
+ locale: "de",
70
+ variables: { header: "Runtime override" },
71
+ },
72
+ },
73
+ rendererCtx(),
74
+ );
75
+
76
+ expect(res.kind).toBe("notification");
77
+ if (res.kind === "notification") {
78
+ expect(res.html).toContain("Runtime override");
79
+ expect(res.html).toContain("Template body");
80
+ expect(res.html).not.toContain("Template header");
81
+ }
82
+ });
83
+
84
+ test("falls back to variables when slug is unknown in template-resolver", async () => {
85
+ const res = await adaptToFoundation(
86
+ {
87
+ kind: "notification",
88
+ payload: {
89
+ template: "password-reset",
90
+ variables: { title: "Reset", body: "Click the link" },
91
+ },
92
+ },
93
+ rendererCtx(),
94
+ );
95
+
96
+ expect(res.kind).toBe("notification");
97
+ if (res.kind === "notification") {
98
+ expect(res.html).toContain("Reset");
99
+ expect(res.html).toContain("Click the link");
100
+ }
101
+ });
102
+ });
@@ -1,5 +1,11 @@
1
1
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
- import { RendererError, type RenderRequest, type RenderResponse } from "../renderer-foundation";
2
+ import {
3
+ type RendererContext,
4
+ RendererError,
5
+ type RenderRequest,
6
+ type RenderResponse,
7
+ } from "../renderer-foundation";
8
+ import { resolveNotificationVariables } from "./resolve-variables";
3
9
  import { simpleRenderer } from "./simple-renderer";
4
10
 
5
11
  // Adapter: simpleRenderer.render hat `Promise<string>`-Signatur (Legacy
@@ -9,7 +15,10 @@ import { simpleRenderer } from "./simple-renderer";
9
15
  // Inline-CSS) und packt sie in den RendererPlugin-Contract.
10
16
  //
11
17
  // Exported damit der Adapter-Pfad direkt testbar ist (unit-test).
12
- export async function adaptToFoundation(req: RenderRequest): Promise<RenderResponse> {
18
+ export async function adaptToFoundation(
19
+ req: RenderRequest,
20
+ ctx: RendererContext,
21
+ ): Promise<RenderResponse> {
13
22
  if (req.kind !== "notification") {
14
23
  // Defensiver Guard — Foundation wählt Plugins nur für matching kinds,
15
24
  // dieser Pfad sollte unter normalen Umständen nie erreicht werden.
@@ -18,9 +27,10 @@ export async function adaptToFoundation(req: RenderRequest): Promise<RenderRespo
18
27
  "invalid_payload",
19
28
  );
20
29
  }
30
+ const variables = await resolveNotificationVariables(req, ctx);
21
31
  const html = await simpleRenderer.render({
22
32
  template: req.payload.template ?? "",
23
- variables: req.payload.variables ?? {},
33
+ variables,
24
34
  });
25
35
  return { kind: "notification", html };
26
36
  }
@@ -36,6 +46,7 @@ export function createRendererSimpleFeature(): FeatureDefinition {
36
46
  recommended: false,
37
47
  });
38
48
  r.requires("renderer-foundation");
49
+ r.optionalRequires("template-resolver");
39
50
 
40
51
  r.useExtension("renderer", "simple", {
41
52
  kinds: ["notification"] as const,
@@ -0,0 +1,55 @@
1
+ import type { RendererContext, RenderRequest } from "../renderer-foundation";
2
+ import {
3
+ createTemplateResolverApi,
4
+ FALLBACK_LOCALE,
5
+ TemplateNotFoundError,
6
+ } from "../template-resolver";
7
+
8
+ type NotificationRequest = Extract<RenderRequest, { kind: "notification" }>;
9
+
10
+ /** Merge template-resolver content (plain JSON EmailTemplateData) with runtime variables. */
11
+ export async function resolveNotificationVariables(
12
+ req: NotificationRequest,
13
+ ctx: RendererContext,
14
+ ): Promise<Readonly<Record<string, unknown>>> {
15
+ const variables = req.payload.variables ?? {};
16
+ const slug = req.payload.template?.trim();
17
+ if (!slug || req.payload.content || !ctx.db) {
18
+ return variables;
19
+ }
20
+
21
+ try {
22
+ const api = createTemplateResolverApi(ctx.db);
23
+ const locale = req.payload.locale ?? FALLBACK_LOCALE;
24
+ const resolved = await api.resolveTemplate({
25
+ tenantId: ctx.tenantId,
26
+ slug,
27
+ kind: "notification",
28
+ locale,
29
+ });
30
+ if (resolved.contentFormat === "plain") {
31
+ const base = parsePlainTemplateContent(resolved.content);
32
+ return { ...base, ...variables };
33
+ }
34
+ return { ...variables, body: resolved.content };
35
+ } catch (err) {
36
+ if (err instanceof TemplateNotFoundError) {
37
+ return variables;
38
+ }
39
+ throw err;
40
+ }
41
+ }
42
+
43
+ function parsePlainTemplateContent(content: string): Record<string, unknown> {
44
+ const trimmed = content.trim();
45
+ if (!trimmed) return {};
46
+ try {
47
+ const parsed: unknown = JSON.parse(trimmed);
48
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
49
+ return parsed as Record<string, unknown>;
50
+ }
51
+ } catch {
52
+ // ponytail: non-JSON plain content becomes a single body section downstream
53
+ }
54
+ return { body: content };
55
+ }