@cosmicdrift/kumiko-bundled-features 0.102.2 → 0.104.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/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,138 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,153 +0,0 @@
|
|
|
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
|
-
/** Pfad-Konstanten der 4 Auth-Seiten (relativ zur App-baseUrl). */
|
|
60
|
-
export type AuthPaths = {
|
|
61
|
-
readonly resetPassword: string;
|
|
62
|
-
readonly verifyEmail: string;
|
|
63
|
-
readonly signupComplete: string;
|
|
64
|
-
readonly inviteAccept: string;
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
/** Konventions-Pfade — alle Kumiko-Apps nutzen dieselben. Apps überschreiben
|
|
68
|
-
* nur die Ausnahme via `makeAuthPaths({ ... })`. */
|
|
69
|
-
export const DEFAULT_AUTH_PATHS: AuthPaths = {
|
|
70
|
-
resetPassword: "/reset-password",
|
|
71
|
-
verifyEmail: "/verify-email",
|
|
72
|
-
signupComplete: "/signup/complete",
|
|
73
|
-
inviteAccept: "/invite/accept",
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
export function makeAuthPaths(overrides: Partial<AuthPaths> = {}): AuthPaths {
|
|
77
|
-
return { ...DEFAULT_AUTH_PATHS, ...overrides };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export type CreateAuthMailerConfigArgs = {
|
|
81
|
-
readonly mailSender: EmailTransport;
|
|
82
|
-
readonly hmacSecret: string;
|
|
83
|
-
/** Basis-URL der App inkl. Schema (z.B. "https://admin.example.com").
|
|
84
|
-
* Die factory hängt paths.resetPassword etc. an. */
|
|
85
|
-
readonly baseUrl: string;
|
|
86
|
-
/** Pfad-Konstanten für die Auth-Seiten. Default: DEFAULT_AUTH_PATHS. */
|
|
87
|
-
readonly paths?: AuthPaths;
|
|
88
|
-
/** App-Name für Mail-Subject + Body. Default "Account". */
|
|
89
|
-
readonly appName?: string;
|
|
90
|
-
/** Locale für die Mail-Templates. Default "de". */
|
|
91
|
-
readonly locale?: AuthMailLocale;
|
|
92
|
-
/** Email-verification mode. Default undefined (kein Gate).
|
|
93
|
-
* "strict" blockt Login solange `emailVerified=false`.
|
|
94
|
-
* "off" mountet die Routes ohne login-gating. */
|
|
95
|
-
readonly emailVerificationMode?: "strict" | "off";
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Factory für `AuthMailerConfig` — baut die 4 Auth-Mail-Setups
|
|
100
|
-
* (passwordReset / emailVerification / signup / invite) inklusive
|
|
101
|
-
* `send*Email`-Wrapper + URL-Konstruktion.
|
|
102
|
-
*
|
|
103
|
-
* Jede App ruft das einmal auf und spreadet das Resultat in die
|
|
104
|
-
* `auth`-Option von `runProdApp` / `runDevApp`.
|
|
105
|
-
*/
|
|
106
|
-
export function createAuthMailerConfig(args: CreateAuthMailerConfigArgs): AuthMailerConfig {
|
|
107
|
-
const appName = args.appName ?? "Account";
|
|
108
|
-
const locale = args.locale ?? "de";
|
|
109
|
-
const paths = args.paths ?? DEFAULT_AUTH_PATHS;
|
|
110
|
-
return {
|
|
111
|
-
passwordReset: {
|
|
112
|
-
hmacSecret: args.hmacSecret,
|
|
113
|
-
appResetUrl: `${args.baseUrl}${paths.resetPassword}`,
|
|
114
|
-
sendResetEmail: async ({ email, resetUrl, expiresAt }) => {
|
|
115
|
-
await args.mailSender.send({
|
|
116
|
-
to: email,
|
|
117
|
-
...renderResetPasswordEmail({ resetUrl, expiresAt, locale, appName }),
|
|
118
|
-
});
|
|
119
|
-
},
|
|
120
|
-
},
|
|
121
|
-
emailVerification: {
|
|
122
|
-
hmacSecret: args.hmacSecret,
|
|
123
|
-
...(args.emailVerificationMode !== undefined && {
|
|
124
|
-
mode: args.emailVerificationMode,
|
|
125
|
-
}),
|
|
126
|
-
appVerifyUrl: `${args.baseUrl}${paths.verifyEmail}`,
|
|
127
|
-
sendVerificationEmail: async ({ email, verificationUrl, expiresAt }) => {
|
|
128
|
-
await args.mailSender.send({
|
|
129
|
-
to: email,
|
|
130
|
-
...renderVerifyEmail({ verificationUrl, expiresAt, locale, appName }),
|
|
131
|
-
});
|
|
132
|
-
},
|
|
133
|
-
},
|
|
134
|
-
signup: {
|
|
135
|
-
appActivationUrl: `${args.baseUrl}${paths.signupComplete}`,
|
|
136
|
-
sendActivationEmail: async ({ email, activationUrl, expiresAt }) => {
|
|
137
|
-
await args.mailSender.send({
|
|
138
|
-
to: email,
|
|
139
|
-
...renderActivationEmail({ activationUrl, expiresAt, locale, appName }),
|
|
140
|
-
});
|
|
141
|
-
},
|
|
142
|
-
},
|
|
143
|
-
invite: {
|
|
144
|
-
appAcceptUrl: `${args.baseUrl}${paths.inviteAccept}`,
|
|
145
|
-
sendInviteEmail: async ({ email, inviteUrl, expiresAt, role }) => {
|
|
146
|
-
await args.mailSender.send({
|
|
147
|
-
to: email,
|
|
148
|
-
...renderInviteEmail({ inviteUrl, expiresAt, role, locale, appName }),
|
|
149
|
-
});
|
|
150
|
-
},
|
|
151
|
-
},
|
|
152
|
-
};
|
|
153
|
-
}
|