@cosmicdrift/kumiko-bundled-features 0.34.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.34.2",
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.21.0",
78
- "@cosmicdrift/kumiko-framework": "0.21.0",
79
- "@cosmicdrift/kumiko-renderer": "0.21.0",
80
- "@cosmicdrift/kumiko-renderer-web": "0.21.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="${escapeAttr(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
+ 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="${escapeAttr(args.url)}" style="color: #1a1a1a; word-break: break-all;">${escapeHtml(args.url)}</a></p>`;
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
276
- }
277
- function escapeAttr(s: string): string {
278
- return s
279
- .replace(/&/g, "&amp;")
280
- .replace(/"/g, "&quot;")
281
- .replace(/</g, "&lt;")
282
- .replace(/>/g, "&gt;");
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, "&amp;")
61
- .replace(/</g, "&lt;")
62
- .replace(/>/g, "&gt;")
63
- .replace(/"/g, "&quot;")
64
- .replace(/'/g, "&#39;");
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, "&amp;")
20
- .replace(/</g, "&lt;")
21
- .replace(/>/g, "&gt;")
22
- .replace(/"/g, "&quot;");
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>`;