@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.
- package/package.json +6 -6
- package/src/auth-email-password/__tests__/email-templates.test.ts +44 -47
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +41 -21
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +62 -32
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +39 -20
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +39 -8
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +53 -36
- package/src/auth-email-password/auth-paths.ts +24 -0
- package/src/auth-email-password/email-templates.ts +59 -118
- package/src/auth-email-password/feature.ts +19 -1
- package/src/auth-email-password/handlers/invite-create.write.ts +33 -5
- package/src/auth-email-password/handlers/request-email-verification.write.ts +5 -0
- package/src/auth-email-password/handlers/request-password-reset.write.ts +5 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +38 -8
- package/src/auth-email-password/handlers/token-request-handler.ts +31 -0
- package/src/auth-email-password/index.ts +9 -20
- package/src/auth-email-password/magic-link-mail.ts +62 -0
- package/src/renderer-simple/__tests__/adapter.test.ts +38 -19
- package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +102 -0
- package/src/renderer-simple/feature.ts +14 -3
- package/src/renderer-simple/resolve-variables.ts +55 -0
- package/src/auth-email-password/__tests__/auth-mailer.test.ts +0 -138
- 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
|
-
//
|
|
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
|
|
5
|
-
//
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
|
|
21
|
-
RenderedEmail,
|
|
11
|
+
AuthMailSection,
|
|
22
12
|
RenderInviteEmailArgs,
|
|
23
|
-
|
|
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 {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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 {
|
|
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(
|
|
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
|
|
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
|
+
}
|