@cosmicdrift/kumiko-bundled-features 0.35.0 → 0.37.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 +5 -5
- package/src/auth-email-password/__tests__/auth-mailer.test.ts +138 -0
- package/src/auth-email-password/auth-mailer.ts +137 -0
- package/src/auth-email-password/email-templates.ts +3 -13
- package/src/auth-email-password/index.ts +9 -0
- package/src/legal-pages/markdown.ts +1 -13
- package/src/renderer-simple/simple-renderer.ts +1 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -74,10 +74,10 @@
|
|
|
74
74
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
75
75
|
},
|
|
76
76
|
"dependencies": {
|
|
77
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
78
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
79
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
80
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
77
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.35.0",
|
|
78
|
+
"@cosmicdrift/kumiko-framework": "0.35.0",
|
|
79
|
+
"@cosmicdrift/kumiko-renderer": "0.35.0",
|
|
80
|
+
"@cosmicdrift/kumiko-renderer-web": "0.35.0",
|
|
81
81
|
"@mollie/api-client": "^4.5.0",
|
|
82
82
|
"@node-rs/argon2": "^2.0.2",
|
|
83
83
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createInMemoryTransport } from "../../channel-email";
|
|
3
|
+
import { createAuthMailerConfig } from "../auth-mailer";
|
|
4
|
+
|
|
5
|
+
function makeArgs(overrides?: Record<string, unknown>) {
|
|
6
|
+
const mailSender = createInMemoryTransport();
|
|
7
|
+
return {
|
|
8
|
+
mailSender,
|
|
9
|
+
hmacSecret: "test-hmac-secret",
|
|
10
|
+
baseUrl: "https://admin.example.com",
|
|
11
|
+
paths: {
|
|
12
|
+
resetPassword: "/reset-password",
|
|
13
|
+
verifyEmail: "/verify-email",
|
|
14
|
+
signupComplete: "/signup/complete",
|
|
15
|
+
inviteAccept: "/invite/accept",
|
|
16
|
+
},
|
|
17
|
+
appName: "TestApp",
|
|
18
|
+
locale: "en" as const,
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("createAuthMailerConfig", () => {
|
|
24
|
+
test("returns all 4 setups", () => {
|
|
25
|
+
const config = createAuthMailerConfig(makeArgs());
|
|
26
|
+
expect(config.passwordReset).toBeDefined();
|
|
27
|
+
expect(config.emailVerification).toBeDefined();
|
|
28
|
+
expect(config.signup).toBeDefined();
|
|
29
|
+
expect(config.invite).toBeDefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("constructs URLs from baseUrl + paths", () => {
|
|
33
|
+
const config = createAuthMailerConfig(makeArgs());
|
|
34
|
+
expect(config.passwordReset.appResetUrl).toBe("https://admin.example.com/reset-password");
|
|
35
|
+
expect(config.emailVerification.appVerifyUrl).toBe("https://admin.example.com/verify-email");
|
|
36
|
+
expect(config.signup.appActivationUrl).toBe("https://admin.example.com/signup/complete");
|
|
37
|
+
expect(config.invite.appAcceptUrl).toBe("https://admin.example.com/invite/accept");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("forwards hmacSecret", () => {
|
|
41
|
+
const config = createAuthMailerConfig(makeArgs());
|
|
42
|
+
expect(config.passwordReset.hmacSecret).toBe("test-hmac-secret");
|
|
43
|
+
expect(config.emailVerification.hmacSecret).toBe("test-hmac-secret");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("sendResetEmail calls mailSender.send with rendered content", async () => {
|
|
47
|
+
const args = makeArgs();
|
|
48
|
+
const config = createAuthMailerConfig(args);
|
|
49
|
+
|
|
50
|
+
await config.passwordReset.sendResetEmail({
|
|
51
|
+
email: "user@example.com",
|
|
52
|
+
resetUrl: "https://admin.example.com/reset?token=abc",
|
|
53
|
+
expiresAt: "2026-06-09T12:00:00.000Z",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(args.mailSender.sent).toHaveLength(1);
|
|
57
|
+
expect(args.mailSender.sent[0]!.to).toBe("user@example.com");
|
|
58
|
+
expect(args.mailSender.sent[0]!.subject).toContain("TestApp");
|
|
59
|
+
expect(args.mailSender.sent[0]!.subject).toContain("Reset");
|
|
60
|
+
expect(args.mailSender.sent[0]!.html).toContain("https://admin.example.com/reset?token=abc");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("sendVerificationEmail calls mailSender.send", async () => {
|
|
64
|
+
const args = makeArgs();
|
|
65
|
+
const config = createAuthMailerConfig(args);
|
|
66
|
+
|
|
67
|
+
await config.emailVerification.sendVerificationEmail({
|
|
68
|
+
email: "user@example.com",
|
|
69
|
+
verificationUrl: "https://admin.example.com/verify?token=abc",
|
|
70
|
+
expiresAt: "2026-06-09T12:00:00.000Z",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(args.mailSender.sent).toHaveLength(1);
|
|
74
|
+
expect(args.mailSender.sent[0]!.to).toBe("user@example.com");
|
|
75
|
+
expect(args.mailSender.sent[0]!.subject).toContain("TestApp");
|
|
76
|
+
expect(args.mailSender.sent[0]!.subject).toContain("Verify");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("sendActivationEmail calls mailSender.send", async () => {
|
|
80
|
+
const args = makeArgs();
|
|
81
|
+
const config = createAuthMailerConfig(args);
|
|
82
|
+
|
|
83
|
+
await config.signup.sendActivationEmail({
|
|
84
|
+
email: "user@example.com",
|
|
85
|
+
activationUrl: "https://admin.example.com/signup/complete?token=abc",
|
|
86
|
+
expiresAt: "2026-06-09T12:00:00.000Z",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(args.mailSender.sent).toHaveLength(1);
|
|
90
|
+
expect(args.mailSender.sent[0]!.to).toBe("user@example.com");
|
|
91
|
+
expect(args.mailSender.sent[0]!.subject).toContain("TestApp");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("sendInviteEmail calls mailSender.send with role", async () => {
|
|
95
|
+
const args = makeArgs();
|
|
96
|
+
const config = createAuthMailerConfig(args);
|
|
97
|
+
|
|
98
|
+
await config.invite.sendInviteEmail({
|
|
99
|
+
email: "user@example.com",
|
|
100
|
+
inviteUrl: "https://admin.example.com/invite/accept?token=abc",
|
|
101
|
+
expiresAt: "2026-06-09T12:00:00.000Z",
|
|
102
|
+
role: "Admin",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(args.mailSender.sent).toHaveLength(1);
|
|
106
|
+
expect(args.mailSender.sent[0]!.to).toBe("user@example.com");
|
|
107
|
+
expect(args.mailSender.sent[0]!.subject).toContain("TestApp");
|
|
108
|
+
expect(args.mailSender.sent[0]!.html).toContain("Admin");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("uses defaults when appName is omitted", () => {
|
|
112
|
+
const config = createAuthMailerConfig(makeArgs({ appName: undefined }));
|
|
113
|
+
expect(config.passwordReset.appResetUrl).toBeDefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("emailVerificationMode is absent when not provided", () => {
|
|
117
|
+
const config = createAuthMailerConfig(makeArgs());
|
|
118
|
+
expect("mode" in config.emailVerification).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("emailVerificationMode is set when provided", () => {
|
|
122
|
+
const config = createAuthMailerConfig(makeArgs({ emailVerificationMode: "strict" }));
|
|
123
|
+
expect(config.emailVerification).toHaveProperty("mode", "strict");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("locale 'de' renders German subject", async () => {
|
|
127
|
+
const args = makeArgs({ locale: "de" });
|
|
128
|
+
const config = createAuthMailerConfig(args);
|
|
129
|
+
|
|
130
|
+
await config.passwordReset.sendResetEmail({
|
|
131
|
+
email: "user@example.com",
|
|
132
|
+
resetUrl: "https://example.com/reset?token=abc",
|
|
133
|
+
expiresAt: "2026-06-09T12:00:00.000Z",
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(args.mailSender.sent[0]!.subject).toContain("Passwort");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { EmailTransport } from "../channel-email";
|
|
2
|
+
import type { AuthMailLocale } from "./email-templates";
|
|
3
|
+
import {
|
|
4
|
+
renderActivationEmail,
|
|
5
|
+
renderInviteEmail,
|
|
6
|
+
renderResetPasswordEmail,
|
|
7
|
+
renderVerifyEmail,
|
|
8
|
+
} from "./email-templates";
|
|
9
|
+
import type {
|
|
10
|
+
EmailVerificationOptions,
|
|
11
|
+
InviteOptions,
|
|
12
|
+
PasswordResetOptions,
|
|
13
|
+
SignupOptions,
|
|
14
|
+
} from "./feature";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Komplette Konfiguration für die 4 Auth-Mail-Flows einer App.
|
|
18
|
+
*
|
|
19
|
+
* Strukturell kompatibel mit den `*Setup`-Typen aus
|
|
20
|
+
* `@cosmicdrift/kumiko-dev-server` (`PasswordResetSetup`,
|
|
21
|
+
* `EmailVerificationSetup`, `SignupSetup`, `InviteSetup`).
|
|
22
|
+
*/
|
|
23
|
+
export type AuthMailerConfig = {
|
|
24
|
+
readonly passwordReset: PasswordResetOptions & {
|
|
25
|
+
readonly appResetUrl: string;
|
|
26
|
+
readonly sendResetEmail: (args: {
|
|
27
|
+
email: string;
|
|
28
|
+
resetUrl: string;
|
|
29
|
+
expiresAt: string;
|
|
30
|
+
}) => Promise<void>;
|
|
31
|
+
};
|
|
32
|
+
readonly emailVerification: EmailVerificationOptions & {
|
|
33
|
+
readonly appVerifyUrl: string;
|
|
34
|
+
readonly sendVerificationEmail: (args: {
|
|
35
|
+
email: string;
|
|
36
|
+
verificationUrl: string;
|
|
37
|
+
expiresAt: string;
|
|
38
|
+
}) => Promise<void>;
|
|
39
|
+
};
|
|
40
|
+
readonly signup: SignupOptions & {
|
|
41
|
+
readonly appActivationUrl: string;
|
|
42
|
+
readonly sendActivationEmail: (args: {
|
|
43
|
+
email: string;
|
|
44
|
+
activationUrl: string;
|
|
45
|
+
expiresAt: string;
|
|
46
|
+
}) => Promise<void>;
|
|
47
|
+
};
|
|
48
|
+
readonly invite: InviteOptions & {
|
|
49
|
+
readonly appAcceptUrl: string;
|
|
50
|
+
readonly sendInviteEmail: (args: {
|
|
51
|
+
email: string;
|
|
52
|
+
inviteUrl: string;
|
|
53
|
+
expiresAt: string;
|
|
54
|
+
role: string;
|
|
55
|
+
}) => Promise<void>;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type CreateAuthMailerConfigArgs = {
|
|
60
|
+
readonly mailSender: EmailTransport;
|
|
61
|
+
readonly hmacSecret: string;
|
|
62
|
+
/** Basis-URL der App inkl. Schema (z.B. "https://admin.example.com").
|
|
63
|
+
* Die factory hängt paths.resetPassword etc. an. */
|
|
64
|
+
readonly baseUrl: string;
|
|
65
|
+
/** Pfad-Konstanten für die Auth-Seiten — jede App hat ihre eigenen
|
|
66
|
+
* in `./auth-paths.ts`. */
|
|
67
|
+
readonly paths: {
|
|
68
|
+
readonly resetPassword: string;
|
|
69
|
+
readonly verifyEmail: string;
|
|
70
|
+
readonly signupComplete: string;
|
|
71
|
+
readonly inviteAccept: string;
|
|
72
|
+
};
|
|
73
|
+
/** App-Name für Mail-Subject + Body. Default "Account". */
|
|
74
|
+
readonly appName?: string;
|
|
75
|
+
/** Locale für die Mail-Templates. Default "de". */
|
|
76
|
+
readonly locale?: AuthMailLocale;
|
|
77
|
+
/** Email-verification mode. Default undefined (kein Gate).
|
|
78
|
+
* "strict" blockt Login solange `emailVerified=false`.
|
|
79
|
+
* "off" mountet die Routes ohne login-gating. */
|
|
80
|
+
readonly emailVerificationMode?: "strict" | "off";
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Factory für `AuthMailerConfig` — baut die 4 Auth-Mail-Setups
|
|
85
|
+
* (passwordReset / emailVerification / signup / invite) inklusive
|
|
86
|
+
* `send*Email`-Wrapper + URL-Konstruktion.
|
|
87
|
+
*
|
|
88
|
+
* Jede App ruft das einmal auf und spreadet das Resultat in die
|
|
89
|
+
* `auth`-Option von `runProdApp` / `runDevApp`.
|
|
90
|
+
*/
|
|
91
|
+
export function createAuthMailerConfig(args: CreateAuthMailerConfigArgs): AuthMailerConfig {
|
|
92
|
+
const appName = args.appName ?? "Account";
|
|
93
|
+
const locale = args.locale ?? "de";
|
|
94
|
+
return {
|
|
95
|
+
passwordReset: {
|
|
96
|
+
hmacSecret: args.hmacSecret,
|
|
97
|
+
appResetUrl: `${args.baseUrl}${args.paths.resetPassword}`,
|
|
98
|
+
sendResetEmail: async ({ email, resetUrl, expiresAt }) => {
|
|
99
|
+
await args.mailSender.send({
|
|
100
|
+
to: email,
|
|
101
|
+
...renderResetPasswordEmail({ resetUrl, expiresAt, locale, appName }),
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
emailVerification: {
|
|
106
|
+
hmacSecret: args.hmacSecret,
|
|
107
|
+
...(args.emailVerificationMode !== undefined && {
|
|
108
|
+
mode: args.emailVerificationMode,
|
|
109
|
+
}),
|
|
110
|
+
appVerifyUrl: `${args.baseUrl}${args.paths.verifyEmail}`,
|
|
111
|
+
sendVerificationEmail: async ({ email, verificationUrl, expiresAt }) => {
|
|
112
|
+
await args.mailSender.send({
|
|
113
|
+
to: email,
|
|
114
|
+
...renderVerifyEmail({ verificationUrl, expiresAt, locale, appName }),
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
signup: {
|
|
119
|
+
appActivationUrl: `${args.baseUrl}${args.paths.signupComplete}`,
|
|
120
|
+
sendActivationEmail: async ({ email, activationUrl, expiresAt }) => {
|
|
121
|
+
await args.mailSender.send({
|
|
122
|
+
to: email,
|
|
123
|
+
...renderActivationEmail({ activationUrl, expiresAt, locale, appName }),
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
invite: {
|
|
128
|
+
appAcceptUrl: `${args.baseUrl}${args.paths.inviteAccept}`,
|
|
129
|
+
sendInviteEmail: async ({ email, inviteUrl, expiresAt, role }) => {
|
|
130
|
+
await args.mailSender.send({
|
|
131
|
+
to: email,
|
|
132
|
+
...renderInviteEmail({ inviteUrl, expiresAt, role, locale, appName }),
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
//
|
|
21
21
|
// Locale: de + en. Apps mit anderen Sprachen rendern selbst.
|
|
22
22
|
|
|
23
|
+
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
23
24
|
import { Temporal } from "temporal-polyfill";
|
|
24
25
|
|
|
25
26
|
export type AuthMailLocale = "de" | "en";
|
|
@@ -182,11 +183,11 @@ function renderShell(args: { title: string; bodyHtml: string }): string {
|
|
|
182
183
|
}
|
|
183
184
|
|
|
184
185
|
function renderButton(args: { url: string; label: string }): string {
|
|
185
|
-
return `<a href="${
|
|
186
|
+
return `<a href="${escapeHtmlAttr(args.url)}" style="display: inline-block; background: #1a1a1a; color: #ffffff; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 500;">${escapeHtml(args.label)}</a>`;
|
|
186
187
|
}
|
|
187
188
|
|
|
188
189
|
function renderFallbackUrl(args: { url: string; label: string }): string {
|
|
189
|
-
return `<p style="margin: 24px 0 0; font-size: 12px; color: #666;">${escapeHtml(args.label)}<br /><a href="${
|
|
190
|
+
return `<p style="margin: 24px 0 0; font-size: 12px; color: #666;">${escapeHtml(args.label)}<br /><a href="${escapeHtmlAttr(args.url)}" style="color: #1a1a1a; word-break: break-all;">${escapeHtml(args.url)}</a></p>`;
|
|
190
191
|
}
|
|
191
192
|
|
|
192
193
|
export function renderResetPasswordEmail(args: RenderResetPasswordEmailArgs): RenderedEmail {
|
|
@@ -270,14 +271,3 @@ function formatExpiry(iso: string): string {
|
|
|
270
271
|
function pad2(n: number): string {
|
|
271
272
|
return String(n).padStart(2, "0");
|
|
272
273
|
}
|
|
273
|
-
|
|
274
|
-
function escapeHtml(s: string): string {
|
|
275
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
276
|
-
}
|
|
277
|
-
function escapeAttr(s: string): string {
|
|
278
|
-
return s
|
|
279
|
-
.replace(/&/g, "&")
|
|
280
|
-
.replace(/"/g, """)
|
|
281
|
-
.replace(/</g, "<")
|
|
282
|
-
.replace(/>/g, ">");
|
|
283
|
-
}
|
|
@@ -1,3 +1,12 @@
|
|
|
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 CreateAuthMailerConfigArgs,
|
|
8
|
+
createAuthMailerConfig,
|
|
9
|
+
} from "./auth-mailer";
|
|
1
10
|
export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers } from "./constants";
|
|
2
11
|
// Default-HTML-Renderer für die Reset-Password + Verify-Email Mails.
|
|
3
12
|
// Apps wiren die `sendResetEmail` / `sendVerificationEmail` callbacks
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
1
2
|
import { Marked } from "marked";
|
|
2
3
|
|
|
3
4
|
// Markdown→HTML mit eigener `marked`-Instance. GFM aus, breaks aus —
|
|
@@ -54,16 +55,3 @@ ${opts.bodyHtml}
|
|
|
54
55
|
</body>
|
|
55
56
|
</html>`;
|
|
56
57
|
}
|
|
57
|
-
|
|
58
|
-
function escapeHtml(s: string): string {
|
|
59
|
-
return s
|
|
60
|
-
.replace(/&/g, "&")
|
|
61
|
-
.replace(/</g, "<")
|
|
62
|
-
.replace(/>/g, ">")
|
|
63
|
-
.replace(/"/g, """)
|
|
64
|
-
.replace(/'/g, "'");
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function escapeHtmlAttr(s: string): string {
|
|
68
|
-
return escapeHtml(s);
|
|
69
|
-
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { escapeHtml } from "@cosmicdrift/kumiko-headless";
|
|
1
2
|
import type { NotificationRenderer } from "../delivery";
|
|
2
3
|
|
|
3
4
|
type Section =
|
|
@@ -14,14 +15,6 @@ type EmailTemplateData = {
|
|
|
14
15
|
readonly body?: string;
|
|
15
16
|
};
|
|
16
17
|
|
|
17
|
-
function escapeHtml(str: string): string {
|
|
18
|
-
return str
|
|
19
|
-
.replace(/&/g, "&")
|
|
20
|
-
.replace(/</g, "<")
|
|
21
|
-
.replace(/>/g, ">")
|
|
22
|
-
.replace(/"/g, """);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
18
|
function renderSection(section: Section): string {
|
|
26
19
|
if ("text" in section) {
|
|
27
20
|
return `<p style="margin:0 0 16px;color:#333;font-size:14px;line-height:1.5">${escapeHtml(section.text)}</p>`;
|