@cosmicdrift/kumiko-dev-server 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 +3 -3
- package/src/__tests__/boot-extra-context.test.ts +28 -2
- package/src/__tests__/compose-features-wiring.integration.test.ts +92 -63
- package/src/__tests__/compose-features.test.ts +15 -2
- package/src/__tests__/resolve-auth-mail.test.ts +12 -11
- package/src/__tests__/run-dev-app.integration.test.ts +8 -1
- package/src/compose-features.ts +30 -31
- package/src/run-dev-app.ts +10 -17
- package/src/run-prod-app.ts +96 -85
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.105.0",
|
|
4
4
|
"description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"kumiko-schema-check": "./bin/kumiko-schema-check.ts"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
54
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
53
|
+
"@cosmicdrift/kumiko-bundled-features": "0.105.0",
|
|
54
|
+
"@cosmicdrift/kumiko-framework": "0.105.0",
|
|
55
55
|
"ts-morph": "^28.0.0"
|
|
56
56
|
},
|
|
57
57
|
"publishConfig": {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createDeliveryFeature } from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
2
3
|
import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
3
4
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
5
|
import { createRegistry, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -6,8 +7,10 @@ import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
|
6
7
|
import { buildBootExtraContext } from "../run-prod-app";
|
|
7
8
|
|
|
8
9
|
// Pins runProdApp's framework-default-provider autowire (buildBootExtraContext):
|
|
9
|
-
// textContent unconditional, secrets feature-gated, KEK-env trap avoided,
|
|
10
|
-
//
|
|
10
|
+
// textContent unconditional, secrets feature-gated, KEK-env trap avoided, the
|
|
11
|
+
// money-horse regression (no secrets feature → no forced KEK → no throw), and
|
|
12
|
+
// the delivery _notifyFactory wiring that makes ctx.notify work in prod (only
|
|
13
|
+
// test-wired before — runProdApp/runDevApp call THIS, not createDeliveryTestContext).
|
|
11
14
|
|
|
12
15
|
// db is never touched at construction time — createTextContentApi /
|
|
13
16
|
// createSecretsContext only store the handle and query lazily. A bare stub
|
|
@@ -30,6 +33,29 @@ describe("buildBootExtraContext — framework-default provider autowire", () =>
|
|
|
30
33
|
expect(typeof (ctx["textContent"] as { getBlock?: unknown }).getBlock).toBe("function");
|
|
31
34
|
expect(ctx["secrets"]).toBeUndefined();
|
|
32
35
|
expect(ctx["configResolver"]).toBeUndefined();
|
|
36
|
+
expect(ctx["_notifyFactory"]).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("delivery feature mounted → _notifyFactory wired (ctx.notify works in prod)", () => {
|
|
40
|
+
const ctx = buildBootExtraContext({
|
|
41
|
+
db: fakeDb,
|
|
42
|
+
features: [createDeliveryFeature()],
|
|
43
|
+
envSource: {},
|
|
44
|
+
registry,
|
|
45
|
+
hasAuth: false,
|
|
46
|
+
});
|
|
47
|
+
expect(typeof ctx["_notifyFactory"]).toBe("function");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("no delivery feature → _notifyFactory not wired", () => {
|
|
51
|
+
const ctx = buildBootExtraContext({
|
|
52
|
+
db: fakeDb,
|
|
53
|
+
features: [otherFeature],
|
|
54
|
+
envSource: {},
|
|
55
|
+
registry,
|
|
56
|
+
hasAuth: false,
|
|
57
|
+
});
|
|
58
|
+
expect(ctx["_notifyFactory"]).toBeUndefined();
|
|
33
59
|
});
|
|
34
60
|
|
|
35
61
|
test("secrets feature mounted + valid KEK env → ctx.secrets wired", () => {
|
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
// Test fährt den vollen HTTP-Roundtrip und kann den dispatch-error nicht
|
|
17
17
|
// übersehen.
|
|
18
18
|
//
|
|
19
|
-
// Kein Mocking: setupTestStack bootet echte DB + Redis.
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
19
|
+
// Kein Mocking: setupTestStack bootet echte DB + Redis. reset/verify mailen
|
|
20
|
+
// via delivery (ctx.notify → channel-email); der In-Memory-Transport fängt
|
|
21
|
+
// die echte Mail ab — gewollter Capture ohne Mock-API (CLAUDE.md "Kein Mock
|
|
22
|
+
// in *.integration.ts").
|
|
23
23
|
|
|
24
24
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
25
25
|
import { randomBytes } from "node:crypto";
|
|
@@ -28,10 +28,25 @@ import {
|
|
|
28
28
|
AuthHandlers,
|
|
29
29
|
hashPassword,
|
|
30
30
|
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
31
|
+
import {
|
|
32
|
+
createChannelEmailFeature,
|
|
33
|
+
createInMemoryTransport,
|
|
34
|
+
} from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
31
35
|
import {
|
|
32
36
|
configValuesTable,
|
|
33
37
|
createConfigResolver,
|
|
34
38
|
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
39
|
+
import {
|
|
40
|
+
createDeliveryFeature,
|
|
41
|
+
createDeliveryTestContext,
|
|
42
|
+
notificationPreferencesTable,
|
|
43
|
+
} from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
44
|
+
import { createRendererFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
|
|
45
|
+
import {
|
|
46
|
+
createRendererSimpleFeature,
|
|
47
|
+
simpleRenderer,
|
|
48
|
+
} from "@cosmicdrift/kumiko-bundled-features/renderer-simple";
|
|
49
|
+
import { createTemplateResolverFeature } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
35
50
|
import { tenantEntity, tenantMembershipsTable } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
36
51
|
import { seedTenantMembership } from "@cosmicdrift/kumiko-bundled-features/tenant/testing";
|
|
37
52
|
import { UserHandlers, userEntity, userTable } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
@@ -53,38 +68,59 @@ const APP_VERIFY_URL = "https://app.example.com/verify-email";
|
|
|
53
68
|
const TEST_TENANT_ID: TenantId = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
54
69
|
const systemAdmin = TestUsers.systemAdmin;
|
|
55
70
|
|
|
56
|
-
|
|
57
|
-
|
|
71
|
+
// Pulls the magic-link out of the rendered mail HTML — the renderer-simple
|
|
72
|
+
// button carries it as the only href. Replaces the old callback-captured
|
|
73
|
+
// resetUrl now that reset/verify mail via delivery.
|
|
74
|
+
function tokenUrlFromHtml(html: string): URL {
|
|
75
|
+
const match = html.match(/href="([^"]*\?token=[^"]*)"/);
|
|
76
|
+
if (!match?.[1]) throw new Error("no magic-link href in mail html");
|
|
77
|
+
return new URL(match[1]);
|
|
78
|
+
}
|
|
58
79
|
|
|
59
80
|
async function bootStack(
|
|
60
81
|
authOptionsKind: "with-both" | "with-reset-only" | "without-auth-options",
|
|
61
|
-
): Promise<{
|
|
62
|
-
|
|
63
|
-
resetEmails: CapturedReset[];
|
|
64
|
-
verifyEmails: CapturedVerify[];
|
|
65
|
-
}> {
|
|
66
|
-
const resetEmails: CapturedReset[] = [];
|
|
67
|
-
const verifyEmails: CapturedVerify[] = [];
|
|
82
|
+
): Promise<{ stack: TestStack; emailTransport: ReturnType<typeof createInMemoryTransport> }> {
|
|
83
|
+
const emailTransport = createInMemoryTransport();
|
|
68
84
|
|
|
69
85
|
// Genau das was runProdApp/runDevApp machen würde — composeFeatures als
|
|
70
|
-
// single-source der Feature-Liste
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
// single-source der Feature-Liste. delivery + channel-email kommen als
|
|
87
|
+
// App-Features dazu, weil reset/verify ihre Mail via ctx.notify schicken.
|
|
88
|
+
const features = composeFeatures(
|
|
89
|
+
[
|
|
90
|
+
createTemplateResolverFeature(),
|
|
91
|
+
createRendererFoundationFeature(),
|
|
92
|
+
createDeliveryFeature(),
|
|
93
|
+
createRendererSimpleFeature(),
|
|
94
|
+
createChannelEmailFeature({
|
|
95
|
+
transport: emailTransport,
|
|
96
|
+
renderer: simpleRenderer,
|
|
97
|
+
resolveEmail: async () => "unused@test.local",
|
|
98
|
+
}),
|
|
99
|
+
],
|
|
100
|
+
{
|
|
101
|
+
includeBundled: true,
|
|
102
|
+
...(authOptionsKind !== "without-auth-options" && {
|
|
103
|
+
authOptions: {
|
|
104
|
+
passwordReset: { hmacSecret: RESET_HMAC, tokenTtlMinutes: 15, appUrl: APP_RESET_URL },
|
|
105
|
+
...(authOptionsKind === "with-both" && {
|
|
106
|
+
emailVerification: {
|
|
107
|
+
hmacSecret: VERIFY_HMAC,
|
|
108
|
+
tokenTtlMinutes: 60,
|
|
109
|
+
mode: "off",
|
|
110
|
+
appUrl: APP_VERIFY_URL,
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
},
|
|
114
|
+
}),
|
|
115
|
+
},
|
|
116
|
+
);
|
|
84
117
|
|
|
85
118
|
const stack = await setupTestStack({
|
|
86
119
|
features,
|
|
87
|
-
extraContext:
|
|
120
|
+
extraContext: (deps) => ({
|
|
121
|
+
...createDeliveryTestContext(deps),
|
|
122
|
+
configResolver: createConfigResolver(),
|
|
123
|
+
}),
|
|
88
124
|
authConfig: {
|
|
89
125
|
membershipQuery: "tenant:query:memberships",
|
|
90
126
|
loginHandler: AuthHandlers.login,
|
|
@@ -93,19 +129,11 @@ async function bootStack(
|
|
|
93
129
|
passwordReset: {
|
|
94
130
|
requestHandler: AuthHandlers.requestPasswordReset,
|
|
95
131
|
confirmHandler: AuthHandlers.resetPassword,
|
|
96
|
-
appResetUrl: APP_RESET_URL,
|
|
97
|
-
sendResetEmail: async (args) => {
|
|
98
|
-
resetEmails.push(args);
|
|
99
|
-
},
|
|
100
132
|
},
|
|
101
133
|
...(authOptionsKind === "with-both" && {
|
|
102
134
|
emailVerification: {
|
|
103
135
|
requestHandler: AuthHandlers.requestEmailVerification,
|
|
104
136
|
confirmHandler: AuthHandlers.verifyEmail,
|
|
105
|
-
appVerifyUrl: APP_VERIFY_URL,
|
|
106
|
-
sendVerificationEmail: async (args) => {
|
|
107
|
-
verifyEmails.push(args);
|
|
108
|
-
},
|
|
109
137
|
},
|
|
110
138
|
}),
|
|
111
139
|
},
|
|
@@ -113,9 +141,13 @@ async function bootStack(
|
|
|
113
141
|
|
|
114
142
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
115
143
|
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
116
|
-
await unsafePushTables(stack.db, {
|
|
144
|
+
await unsafePushTables(stack.db, {
|
|
145
|
+
configValuesTable,
|
|
146
|
+
tenantMembershipsTable,
|
|
147
|
+
notificationPreferencesTable,
|
|
148
|
+
});
|
|
117
149
|
|
|
118
|
-
return { stack,
|
|
150
|
+
return { stack, emailTransport };
|
|
119
151
|
}
|
|
120
152
|
|
|
121
153
|
async function seedUser(
|
|
@@ -154,8 +186,7 @@ describe("composeFeatures wiring — passwordReset", () => {
|
|
|
154
186
|
beforeEach(async () => {
|
|
155
187
|
await deleteMany(suite.stack.db, userTable, {});
|
|
156
188
|
await deleteMany(suite.stack.db, tenantMembershipsTable, {});
|
|
157
|
-
suite.
|
|
158
|
-
suite.verifyEmails.length = 0;
|
|
189
|
+
suite.emailTransport.sent.length = 0;
|
|
159
190
|
});
|
|
160
191
|
|
|
161
192
|
test("full reset roundtrip: request → email → reset → login with new password", async () => {
|
|
@@ -170,15 +201,15 @@ describe("composeFeatures wiring — passwordReset", () => {
|
|
|
170
201
|
email: "alice@example.com",
|
|
171
202
|
});
|
|
172
203
|
expect(requestRes.status).toBe(200);
|
|
173
|
-
expect(suite.
|
|
174
|
-
const
|
|
175
|
-
if (!
|
|
176
|
-
expect(
|
|
177
|
-
|
|
178
|
-
// Token aus URL extrahieren — wie der echte User es täte (Mail
|
|
179
|
-
// Browser parsed query-string). Das
|
|
180
|
-
//
|
|
181
|
-
const resetUrl =
|
|
204
|
+
expect(suite.emailTransport.sent).toHaveLength(1);
|
|
205
|
+
const sent = suite.emailTransport.sent[0];
|
|
206
|
+
if (!sent) throw new Error("no email sent");
|
|
207
|
+
expect(sent.to).toBe("alice@example.com");
|
|
208
|
+
|
|
209
|
+
// Token aus der Mail-URL extrahieren — wie der echte User es täte (Mail
|
|
210
|
+
// klicken, Browser parsed query-string). Das pinst den vollen
|
|
211
|
+
// URL-zu-Handler-Roundtrip über delivery.
|
|
212
|
+
const resetUrl = tokenUrlFromHtml(sent.html);
|
|
182
213
|
expect(`${resetUrl.origin}${resetUrl.pathname}`).toBe(APP_RESET_URL);
|
|
183
214
|
const token = resetUrl.searchParams.get("token");
|
|
184
215
|
expect(token).toBeTruthy();
|
|
@@ -228,8 +259,7 @@ describe("composeFeatures wiring — emailVerification", () => {
|
|
|
228
259
|
beforeEach(async () => {
|
|
229
260
|
await deleteMany(suite.stack.db, userTable, {});
|
|
230
261
|
await deleteMany(suite.stack.db, tenantMembershipsTable, {});
|
|
231
|
-
suite.
|
|
232
|
-
suite.verifyEmails.length = 0;
|
|
262
|
+
suite.emailTransport.sent.length = 0;
|
|
233
263
|
});
|
|
234
264
|
|
|
235
265
|
test("emailVerification authOption durchgereicht → request handler dispatched", async () => {
|
|
@@ -242,11 +272,11 @@ describe("composeFeatures wiring — emailVerification", () => {
|
|
|
242
272
|
email: "bob@example.com",
|
|
243
273
|
});
|
|
244
274
|
expect(res.status).toBe(200);
|
|
245
|
-
expect(suite.
|
|
246
|
-
const
|
|
247
|
-
if (!
|
|
248
|
-
expect(
|
|
249
|
-
const verifyUrl =
|
|
275
|
+
expect(suite.emailTransport.sent).toHaveLength(1);
|
|
276
|
+
const sent = suite.emailTransport.sent[0];
|
|
277
|
+
if (!sent) throw new Error("no verification email sent");
|
|
278
|
+
expect(sent.to).toBe("bob@example.com");
|
|
279
|
+
const verifyUrl = tokenUrlFromHtml(sent.html);
|
|
250
280
|
expect(`${verifyUrl.origin}${verifyUrl.pathname}`).toBe(APP_VERIFY_URL);
|
|
251
281
|
expect(verifyUrl.searchParams.get("token")).toBeTruthy();
|
|
252
282
|
});
|
|
@@ -274,8 +304,7 @@ describe("composeFeatures wiring — asymmetric activation", () => {
|
|
|
274
304
|
beforeEach(async () => {
|
|
275
305
|
await deleteMany(suite.stack.db, userTable, {});
|
|
276
306
|
await deleteMany(suite.stack.db, tenantMembershipsTable, {});
|
|
277
|
-
suite.
|
|
278
|
-
suite.verifyEmails.length = 0;
|
|
307
|
+
suite.emailTransport.sent.length = 0;
|
|
279
308
|
});
|
|
280
309
|
|
|
281
310
|
test("nur passwordReset gesetzt → reset-flow live, verify-flow fail-closed", async () => {
|
|
@@ -286,17 +315,17 @@ describe("composeFeatures wiring — asymmetric activation", () => {
|
|
|
286
315
|
email: "alice@example.com",
|
|
287
316
|
});
|
|
288
317
|
expect(resetRes.status).toBe(200);
|
|
289
|
-
expect(suite.
|
|
318
|
+
expect(suite.emailTransport.sent).toHaveLength(1);
|
|
290
319
|
|
|
291
320
|
// Verify-Routes sind in dieser bootStack-Variante NICHT gemounted
|
|
292
321
|
// (authConfig.emailVerification fehlt). Der Endpoint existiert also
|
|
293
322
|
// gar nicht — Hono returnt 404. Unterscheidet sich vom 200-silent-
|
|
294
|
-
// success-Pfad: hier ist die ROUTE selbst nicht da.
|
|
323
|
+
// success-Pfad: hier ist die ROUTE selbst nicht da. Kein zweites Mail.
|
|
295
324
|
const verifyRes = await suite.stack.http.raw("POST", "/api/auth/request-email-verification", {
|
|
296
325
|
email: "alice@example.com",
|
|
297
326
|
});
|
|
298
327
|
expect(verifyRes.status).toBe(404);
|
|
299
|
-
expect(suite.
|
|
328
|
+
expect(suite.emailTransport.sent).toHaveLength(1);
|
|
300
329
|
});
|
|
301
330
|
});
|
|
302
331
|
|
|
@@ -328,7 +357,7 @@ describe("composeFeatures wiring — fail-closed ohne authOptions", () => {
|
|
|
328
357
|
afterEach(async () => {
|
|
329
358
|
await deleteMany(suite.stack.db, userTable, {});
|
|
330
359
|
await deleteMany(suite.stack.db, tenantMembershipsTable, {});
|
|
331
|
-
suite.
|
|
360
|
+
suite.emailTransport.sent.length = 0;
|
|
332
361
|
});
|
|
333
362
|
|
|
334
363
|
test("authOptions fehlt → POST returnt enumeration-safe 200, ABER NULL Mails (der echte Bug-Beweis)", async () => {
|
|
@@ -348,6 +377,6 @@ describe("composeFeatures wiring — fail-closed ohne authOptions", () => {
|
|
|
348
377
|
// Bug. Diese Asymmetrie zwischen den beiden describe-Blöcken IST
|
|
349
378
|
// der Test.
|
|
350
379
|
expect(res.status).toBe(200);
|
|
351
|
-
expect(suite.
|
|
380
|
+
expect(suite.emailTransport.sent).toHaveLength(0);
|
|
352
381
|
});
|
|
353
382
|
});
|
|
@@ -40,7 +40,7 @@ describe("composeFeatures", () => {
|
|
|
40
40
|
test("authOptions.passwordReset → request-password-reset + reset-password handlers registriert", () => {
|
|
41
41
|
const features = composeFeatures([noopFeature], {
|
|
42
42
|
includeBundled: true,
|
|
43
|
-
authOptions: { passwordReset: { hmacSecret: HMAC_SECRET } },
|
|
43
|
+
authOptions: { passwordReset: { hmacSecret: HMAC_SECRET, appUrl: "https://app/reset" } },
|
|
44
44
|
});
|
|
45
45
|
const auth = features.find((f) => f.name === "auth-email-password");
|
|
46
46
|
expect(auth).toBeDefined();
|
|
@@ -53,7 +53,7 @@ describe("composeFeatures", () => {
|
|
|
53
53
|
test("authOptions.emailVerification → request-email-verification + verify-email handlers registriert", () => {
|
|
54
54
|
const features = composeFeatures([noopFeature], {
|
|
55
55
|
includeBundled: true,
|
|
56
|
-
authOptions: { emailVerification: { hmacSecret: HMAC_SECRET } },
|
|
56
|
+
authOptions: { emailVerification: { hmacSecret: HMAC_SECRET, appUrl: "https://app/verify" } },
|
|
57
57
|
});
|
|
58
58
|
const auth = features.find((f) => f.name === "auth-email-password");
|
|
59
59
|
expect(auth).toBeDefined();
|
|
@@ -63,6 +63,19 @@ describe("composeFeatures", () => {
|
|
|
63
63
|
expect(handlerNames).toContain("verify-email");
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
test("authOptions.signup → signup-request + signup-confirm handlers registriert", () => {
|
|
67
|
+
const features = composeFeatures([noopFeature], {
|
|
68
|
+
includeBundled: true,
|
|
69
|
+
authOptions: { signup: { appUrl: "https://app/signup/complete" } },
|
|
70
|
+
});
|
|
71
|
+
const auth = features.find((f) => f.name === "auth-email-password");
|
|
72
|
+
expect(auth).toBeDefined();
|
|
73
|
+
if (!auth) return;
|
|
74
|
+
const handlerNames = Array.from(Object.keys(auth.writeHandlers));
|
|
75
|
+
expect(handlerNames).toContain("signup-request");
|
|
76
|
+
expect(handlerNames).toContain("signup-confirm");
|
|
77
|
+
});
|
|
78
|
+
|
|
66
79
|
test("OHNE authOptions → KEINE reset/verify-handlers (anti-default-deploy-bug)", () => {
|
|
67
80
|
// Genau der Bug der vom Review-Agent gefangen wurde: composeFeatures
|
|
68
81
|
// ohne authOptions registriert die handler nicht. Wenn jemand das
|
|
@@ -28,11 +28,13 @@ describe("resolveAuthMail", () => {
|
|
|
28
28
|
|
|
29
29
|
test("mail + SMTP_HOST → all four flows wired from DEFAULT_AUTH_PATHS", () => {
|
|
30
30
|
const out = resolveAuthMail(withMail, "secret", { SMTP_HOST: "localhost" });
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
expect(out.
|
|
34
|
-
expect(out.
|
|
35
|
-
expect(
|
|
31
|
+
// All four flows carry appUrl as feature options — each handler mails via
|
|
32
|
+
// delivery (ctx.notify); no callback wiring remains.
|
|
33
|
+
expect(out.passwordReset?.appUrl).toBe("https://app.example.com/reset-password");
|
|
34
|
+
expect(out.emailVerification?.appUrl).toBe("https://app.example.com/verify-email");
|
|
35
|
+
expect(out.signup?.appUrl).toBe("https://app.example.com/signup/complete");
|
|
36
|
+
expect(out.invite?.appUrl).toBe("https://app.example.com/invite/accept");
|
|
37
|
+
expect(out.passwordReset?.hmacSecret).toBe("secret");
|
|
36
38
|
});
|
|
37
39
|
|
|
38
40
|
test("mail but NO SMTP_HOST → null-transport guard, flows stay unwired", () => {
|
|
@@ -46,14 +48,13 @@ describe("resolveAuthMail", () => {
|
|
|
46
48
|
...withMail,
|
|
47
49
|
passwordReset: {
|
|
48
50
|
hmacSecret: "h",
|
|
49
|
-
|
|
50
|
-
sendResetEmail: async () => {},
|
|
51
|
+
appUrl: "https://custom.example.com/pw",
|
|
51
52
|
},
|
|
52
53
|
};
|
|
53
54
|
const out = resolveAuthMail(explicit, "secret", { SMTP_HOST: "localhost" });
|
|
54
|
-
expect(out.passwordReset?.
|
|
55
|
+
expect(out.passwordReset?.appUrl).toBe("https://custom.example.com/pw");
|
|
55
56
|
// other flows still come from the mail default
|
|
56
|
-
expect(out.signup?.
|
|
57
|
+
expect(out.signup?.appUrl).toBe("https://app.example.com/signup/complete");
|
|
57
58
|
});
|
|
58
59
|
|
|
59
60
|
test("paths override only affects the named path", () => {
|
|
@@ -62,7 +63,7 @@ describe("resolveAuthMail", () => {
|
|
|
62
63
|
mail: { baseUrl: "https://app.example.com", paths: { resetPassword: "/pw" } },
|
|
63
64
|
};
|
|
64
65
|
const out = resolveAuthMail(pathsOverride, "secret", { SMTP_HOST: "localhost" });
|
|
65
|
-
expect(out.passwordReset?.
|
|
66
|
-
expect(out.emailVerification?.
|
|
66
|
+
expect(out.passwordReset?.appUrl).toBe("https://app.example.com/pw");
|
|
67
|
+
expect(out.emailVerification?.appUrl).toBe("https://app.example.com/verify-email");
|
|
67
68
|
});
|
|
68
69
|
});
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// run-prod-app forwarding pair. Without it a typo or wrong spread-key on the dev
|
|
6
6
|
// path would silently drop the fail-closed guard and dev/prod would diverge.
|
|
7
7
|
|
|
8
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
9
|
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
10
|
import type { KumikoServerHandle } from "../create-kumiko-server";
|
|
11
11
|
import { runDevApp } from "../run-dev-app";
|
|
@@ -24,10 +24,17 @@ const ADMIN = {
|
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
let handle: KumikoServerHandle | undefined;
|
|
27
|
+
const savedJwtSecret = process.env["JWT_SECRET"];
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
process.env["JWT_SECRET"] = "test-rundev-secret-32-chars-min!!!!";
|
|
31
|
+
});
|
|
27
32
|
|
|
28
33
|
afterEach(async () => {
|
|
29
34
|
await handle?.stop();
|
|
30
35
|
handle = undefined;
|
|
36
|
+
if (savedJwtSecret === undefined) delete process.env["JWT_SECRET"];
|
|
37
|
+
else process.env["JWT_SECRET"] = savedJwtSecret;
|
|
31
38
|
});
|
|
32
39
|
|
|
33
40
|
describe("runDevApp — auth allowedOrigins forwarding (#399/1)", () => {
|
package/src/compose-features.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
15
|
type AuthEmailPasswordOptions,
|
|
16
|
+
type AuthMailLocale,
|
|
16
17
|
createAuthEmailPasswordFeature,
|
|
17
18
|
type EmailVerificationOptions,
|
|
18
19
|
type InviteOptions,
|
|
@@ -94,51 +95,49 @@ export type AuthOptionsCarrier = {
|
|
|
94
95
|
* gesetzt sind (composeFeatures default-deny: KEINE handler in der
|
|
95
96
|
* Registry registriert, /api/auth/request-password-reset etc. bleiben
|
|
96
97
|
* 401/404). */
|
|
98
|
+
type MailFlowFields = {
|
|
99
|
+
readonly appUrl: string;
|
|
100
|
+
readonly tokenTtlMinutes?: number;
|
|
101
|
+
readonly appName?: string;
|
|
102
|
+
readonly locale?: AuthMailLocale;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// The magic-link mail fields shared by all four flows. Conditional spreads omit
|
|
106
|
+
// undefined keys (exactOptionalPropertyTypes) and avoid the property-write that
|
|
107
|
+
// trips noPropertyAccessFromIndexSignature on the type-aliased option shapes.
|
|
108
|
+
// reset/verify layer hmacSecret (+ mode) on top.
|
|
109
|
+
function pickMailFields(src: MailFlowFields): MailFlowFields {
|
|
110
|
+
return {
|
|
111
|
+
appUrl: src.appUrl,
|
|
112
|
+
...(src.tokenTtlMinutes !== undefined && { tokenTtlMinutes: src.tokenTtlMinutes }),
|
|
113
|
+
...(src.appName !== undefined && { appName: src.appName }),
|
|
114
|
+
...(src.locale !== undefined && { locale: src.locale }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
97
118
|
export function buildComposeAuthOptions(
|
|
98
119
|
auth: AuthOptionsCarrier | undefined,
|
|
99
120
|
): AuthEmailPasswordOptions | undefined {
|
|
100
121
|
if (!auth) return undefined;
|
|
101
122
|
const opts: { -readonly [K in keyof AuthEmailPasswordOptions]: AuthEmailPasswordOptions[K] } = {};
|
|
102
123
|
if (auth.passwordReset) {
|
|
103
|
-
|
|
124
|
+
opts.passwordReset = {
|
|
104
125
|
hmacSecret: auth.passwordReset.hmacSecret,
|
|
126
|
+
...pickMailFields(auth.passwordReset),
|
|
105
127
|
};
|
|
106
|
-
if (auth.passwordReset.tokenTtlMinutes !== undefined) {
|
|
107
|
-
reset.tokenTtlMinutes = auth.passwordReset.tokenTtlMinutes;
|
|
108
|
-
}
|
|
109
|
-
opts.passwordReset = reset;
|
|
110
128
|
}
|
|
111
129
|
if (auth.emailVerification) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
verify.tokenTtlMinutes = auth.emailVerification.tokenTtlMinutes;
|
|
118
|
-
}
|
|
119
|
-
if (auth.emailVerification.mode !== undefined) {
|
|
120
|
-
verify.mode = auth.emailVerification.mode;
|
|
121
|
-
}
|
|
122
|
-
opts.emailVerification = verify;
|
|
130
|
+
opts.emailVerification = {
|
|
131
|
+
hmacSecret: auth.emailVerification.hmacSecret,
|
|
132
|
+
...pickMailFields(auth.emailVerification),
|
|
133
|
+
...(auth.emailVerification.mode !== undefined && { mode: auth.emailVerification.mode }),
|
|
134
|
+
};
|
|
123
135
|
}
|
|
124
136
|
if (auth.signup) {
|
|
125
|
-
|
|
126
|
-
// SignupRequestOptions, der TS-mapped-type-Pfad löst's als
|
|
127
|
-
// index-signature auf (TS noPropertyAccessFromIndexSignature klagt
|
|
128
|
-
// dann beim Property-write). Plain shape ist klar UND funktioniert.
|
|
129
|
-
const signup: { tokenTtlMinutes?: number } = {};
|
|
130
|
-
if (auth.signup.tokenTtlMinutes !== undefined) {
|
|
131
|
-
signup.tokenTtlMinutes = auth.signup.tokenTtlMinutes;
|
|
132
|
-
}
|
|
133
|
-
opts.signup = signup;
|
|
137
|
+
opts.signup = pickMailFields(auth.signup);
|
|
134
138
|
}
|
|
135
139
|
if (auth.invite) {
|
|
136
|
-
|
|
137
|
-
const invite: { tokenTtlMinutes?: number } = {};
|
|
138
|
-
if (auth.invite.tokenTtlMinutes !== undefined) {
|
|
139
|
-
invite.tokenTtlMinutes = auth.invite.tokenTtlMinutes;
|
|
140
|
-
}
|
|
141
|
-
opts.invite = invite;
|
|
140
|
+
opts.invite = pickMailFields(auth.invite);
|
|
142
141
|
}
|
|
143
142
|
return opts.passwordReset || opts.emailVerification || opts.signup || opts.invite
|
|
144
143
|
? opts
|
package/src/run-dev-app.ts
CHANGED
|
@@ -73,7 +73,12 @@ import type {
|
|
|
73
73
|
PasswordResetSetup,
|
|
74
74
|
SignupSetup,
|
|
75
75
|
} from "./run-prod-app";
|
|
76
|
-
import {
|
|
76
|
+
import {
|
|
77
|
+
addConfigAccessorFactory,
|
|
78
|
+
buildBootExtraContext,
|
|
79
|
+
requireEnv,
|
|
80
|
+
resolveAuthMail,
|
|
81
|
+
} from "./run-prod-app";
|
|
77
82
|
|
|
78
83
|
export type RunDevAppAuthOptions = {
|
|
79
84
|
/** Admin user to seed at boot. Idempotent — re-runs in persistent-DB
|
|
@@ -210,15 +215,10 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
210
215
|
// auseinanderdriften können).
|
|
211
216
|
const envSource = options.envSource ?? process.env;
|
|
212
217
|
// auth.mail → normalisiert in die expliziten Flow-Felder (symmetrisch zu
|
|
213
|
-
// runProdApp). hmacSecret = JWT_SECRET-env
|
|
214
|
-
//
|
|
215
|
-
// Apps). Ab hier IMMER effectiveAuth statt options.auth.
|
|
218
|
+
// runProdApp). hmacSecret = JWT_SECRET-env (fail-fast, symmetrisch zu
|
|
219
|
+
// runProdApp). Ab hier IMMER effectiveAuth statt options.auth.
|
|
216
220
|
const effectiveAuth = options.auth
|
|
217
|
-
? resolveAuthMail(
|
|
218
|
-
options.auth,
|
|
219
|
-
envSource["JWT_SECRET"] ?? "kumiko-dev-auth-mail-hmac-fallback-secret",
|
|
220
|
-
envSource,
|
|
221
|
-
)
|
|
221
|
+
? resolveAuthMail(options.auth, requireEnv("JWT_SECRET", envSource, "runDevApp"), envSource)
|
|
222
222
|
: undefined;
|
|
223
223
|
const composeAuthOptions = buildComposeAuthOptions(effectiveAuth);
|
|
224
224
|
const features = composeFeatures(options.features, {
|
|
@@ -318,6 +318,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
318
318
|
envSource,
|
|
319
319
|
registry: deps.registry,
|
|
320
320
|
hasAuth: false,
|
|
321
|
+
sseBroker: deps.sseBroker,
|
|
321
322
|
...(options.masterKey && { masterKey: options.masterKey }),
|
|
322
323
|
});
|
|
323
324
|
const base = typeof cfgExtra === "function" ? cfgExtra(deps) : (cfgExtra ?? {});
|
|
@@ -395,24 +396,18 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
395
396
|
passwordReset: {
|
|
396
397
|
requestHandler: AuthHandlers.requestPasswordReset,
|
|
397
398
|
confirmHandler: AuthHandlers.resetPassword,
|
|
398
|
-
sendResetEmail: effectiveAuth.passwordReset.sendResetEmail,
|
|
399
|
-
appResetUrl: effectiveAuth.passwordReset.appResetUrl,
|
|
400
399
|
},
|
|
401
400
|
}),
|
|
402
401
|
...(effectiveAuth.emailVerification && {
|
|
403
402
|
emailVerification: {
|
|
404
403
|
requestHandler: AuthHandlers.requestEmailVerification,
|
|
405
404
|
confirmHandler: AuthHandlers.verifyEmail,
|
|
406
|
-
sendVerificationEmail: effectiveAuth.emailVerification.sendVerificationEmail,
|
|
407
|
-
appVerifyUrl: effectiveAuth.emailVerification.appVerifyUrl,
|
|
408
405
|
},
|
|
409
406
|
}),
|
|
410
407
|
...(effectiveAuth.signup && {
|
|
411
408
|
signup: {
|
|
412
409
|
requestHandler: AuthHandlers.signupRequest,
|
|
413
410
|
confirmHandler: AuthHandlers.signupConfirm,
|
|
414
|
-
sendActivationEmail: effectiveAuth.signup.sendActivationEmail,
|
|
415
|
-
appActivationUrl: effectiveAuth.signup.appActivationUrl,
|
|
416
411
|
},
|
|
417
412
|
}),
|
|
418
413
|
...(effectiveAuth.invite && {
|
|
@@ -420,8 +415,6 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
420
415
|
acceptHandler: AuthHandlers.inviteAccept,
|
|
421
416
|
acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
|
|
422
417
|
signupCompleteHandler: AuthHandlers.inviteSignupComplete,
|
|
423
|
-
sendInviteEmail: effectiveAuth.invite.sendInviteEmail,
|
|
424
|
-
appAcceptUrl: effectiveAuth.invite.appAcceptUrl,
|
|
425
418
|
},
|
|
426
419
|
}),
|
|
427
420
|
},
|
package/src/run-prod-app.ts
CHANGED
|
@@ -27,10 +27,8 @@
|
|
|
27
27
|
import {
|
|
28
28
|
AuthErrors,
|
|
29
29
|
AuthHandlers,
|
|
30
|
-
type AuthMailerConfig,
|
|
31
30
|
type AuthMailLocale,
|
|
32
31
|
type AuthPaths,
|
|
33
|
-
createAuthMailerConfig,
|
|
34
32
|
type EmailVerificationOptions,
|
|
35
33
|
type InviteOptions,
|
|
36
34
|
makeAuthPaths,
|
|
@@ -47,6 +45,11 @@ import {
|
|
|
47
45
|
createConfigAccessorFactory,
|
|
48
46
|
createConfigResolver,
|
|
49
47
|
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
48
|
+
import {
|
|
49
|
+
collectChannels,
|
|
50
|
+
createDeliveryService,
|
|
51
|
+
DELIVERY_FEATURE,
|
|
52
|
+
} from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
50
53
|
import {
|
|
51
54
|
createSecretsContext,
|
|
52
55
|
SECRETS_FEATURE_NAME,
|
|
@@ -79,6 +82,7 @@ import {
|
|
|
79
82
|
type EffectiveFeaturesResolver,
|
|
80
83
|
type FeatureDefinition,
|
|
81
84
|
findTierResolverUsage,
|
|
85
|
+
type NotifyFactory,
|
|
82
86
|
type Registry,
|
|
83
87
|
type TenantId,
|
|
84
88
|
type TierResolverPlugin,
|
|
@@ -159,11 +163,15 @@ export function buildBunServeOptions(
|
|
|
159
163
|
// `src` defaults to process.env but is threaded from the caller's envSource
|
|
160
164
|
// so the boot-path reads the SAME env-quelle that was validated above —
|
|
161
165
|
// injected dummies in test-mode must not silently fall back to process.env.
|
|
162
|
-
function requireEnv(
|
|
166
|
+
export function requireEnv(
|
|
167
|
+
name: string,
|
|
168
|
+
src: Record<string, string | undefined> = process.env,
|
|
169
|
+
context = "runProdApp",
|
|
170
|
+
): string {
|
|
163
171
|
const value = src[name];
|
|
164
172
|
if (value === undefined || value === "") {
|
|
165
173
|
throw new Error(
|
|
166
|
-
|
|
174
|
+
`${context}: required env var "${name}" is missing or empty. ` +
|
|
167
175
|
`Set it in your container env / .env.production / Coolify secrets.`,
|
|
168
176
|
);
|
|
169
177
|
}
|
|
@@ -229,62 +237,27 @@ function makeDryRunHandle(): ProdAppHandle {
|
|
|
229
237
|
|
|
230
238
|
/** Wrapper-API für den Password-Reset-Flow.
|
|
231
239
|
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
|
|
237
|
-
* und auth-routes-config für die Mail-Side. extends-Beziehung
|
|
238
|
-
* pinst die Synchronität: jede Feature-Option ist auch Wrapper-Option. */
|
|
239
|
-
export type PasswordResetSetup = PasswordResetOptions & {
|
|
240
|
-
readonly sendResetEmail: (args: {
|
|
241
|
-
email: string;
|
|
242
|
-
resetUrl: string;
|
|
243
|
-
expiresAt: string;
|
|
244
|
-
}) => Promise<void>;
|
|
245
|
-
/** App-URL des ResetPasswordScreen. Framework appended `?token=…`;
|
|
246
|
-
* KEIN trailing `?` oder `#`. Beispiel: "https://admin.example.com/reset-password" */
|
|
247
|
-
readonly appResetUrl: string;
|
|
248
|
-
};
|
|
240
|
+
* Seit der delivery-Migration trägt PasswordResetOptions selbst `appUrl`
|
|
241
|
+
* (+ appName/locale) und der Handler mailt via ctx.notify — kein
|
|
242
|
+
* sendResetEmail-Callback mehr. Apps geben `auth.mail` (Convenience,
|
|
243
|
+
* resolveAuthMail baut die appUrl) ODER einen expliziten Block. */
|
|
244
|
+
export type PasswordResetSetup = PasswordResetOptions;
|
|
249
245
|
|
|
250
246
|
/** Wrapper-API für den Email-Verification-Flow. Symmetrisch zu
|
|
251
|
-
* PasswordResetSetup —
|
|
252
|
-
export type EmailVerificationSetup = EmailVerificationOptions
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
*
|
|
263
|
-
* tokenLength) plus die Mail-Side die der Wrapper an die auth-routes-
|
|
264
|
-
* config durchreicht. Anders als reset/verify gibt's KEIN hmacSecret —
|
|
265
|
-
* Signup-Tokens sind opaque random in Redis, nicht HMAC-signed. */
|
|
266
|
-
export type SignupSetup = SignupOptions & {
|
|
267
|
-
readonly sendActivationEmail: (args: {
|
|
268
|
-
email: string;
|
|
269
|
-
activationUrl: string;
|
|
270
|
-
expiresAt: string;
|
|
271
|
-
}) => Promise<void>;
|
|
272
|
-
readonly appActivationUrl: string;
|
|
273
|
-
};
|
|
274
|
-
|
|
275
|
-
/** Wrapper-API für Tenant-Invite Magic-Link. Drei accept-Branches im
|
|
276
|
-
* framework, der Wrapper reicht NUR die Mail-Side + appAcceptUrl
|
|
277
|
-
* durch — handler-names sind hardcoded in run-prod-app aus
|
|
247
|
+
* PasswordResetSetup — = EmailVerificationOptions (appUrl via delivery). */
|
|
248
|
+
export type EmailVerificationSetup = EmailVerificationOptions;
|
|
249
|
+
|
|
250
|
+
/** Wrapper-API für Magic-Link Self-Signup. = SignupOptions (appUrl, die
|
|
251
|
+
* Mail geht via delivery/ctx.notify wie reset/verify). Anders als reset/
|
|
252
|
+
* verify gibt's KEIN hmacSecret — Signup-Tokens sind opaque random in
|
|
253
|
+
* Redis, nicht HMAC-signed. */
|
|
254
|
+
export type SignupSetup = SignupOptions;
|
|
255
|
+
|
|
256
|
+
/** Wrapper-API für Tenant-Invite Magic-Link. = InviteOptions (appUrl, die
|
|
257
|
+
* Invite-Mail geht via delivery wie reset/verify/signup). Drei accept-
|
|
258
|
+
* Branches im framework; handler-names sind hardcoded in run-prod-app aus
|
|
278
259
|
* AuthHandlers (analog signup). */
|
|
279
|
-
export type InviteSetup = InviteOptions
|
|
280
|
-
readonly sendInviteEmail: (args: {
|
|
281
|
-
email: string;
|
|
282
|
-
inviteUrl: string;
|
|
283
|
-
expiresAt: string;
|
|
284
|
-
role: string;
|
|
285
|
-
}) => Promise<void>;
|
|
286
|
-
readonly appAcceptUrl: string;
|
|
287
|
-
};
|
|
260
|
+
export type InviteSetup = InviteOptions;
|
|
288
261
|
|
|
289
262
|
/** Auth-Mail-Convenience-Optionen — shared zwischen runProdApp + runDevApp.
|
|
290
263
|
* Verdrahtet alle 4 Mail-Flows aus einem env-SMTP-Transport + Standard-
|
|
@@ -319,9 +292,9 @@ export type RunProdAppAuthOptions = {
|
|
|
319
292
|
* (legacy-Verhalten, Kartenhaus existing-Apps unangefasst). */
|
|
320
293
|
readonly sessions?: ProdSessionsOption;
|
|
321
294
|
/** Auth-Mail-Convenience: verdrahtet alle 4 Mail-Flows (passwordReset,
|
|
322
|
-
* emailVerification, signup, invite) aus
|
|
323
|
-
*
|
|
324
|
-
*
|
|
295
|
+
* emailVerification, signup, invite) aus `auth.mail.baseUrl` + Standard-
|
|
296
|
+
* Pfaden. Alle vier mailen via delivery (ctx.notify) — ersetzt das per-App
|
|
297
|
+
* hand-gerollte `send*Email`-Callback-Wiring.
|
|
325
298
|
*
|
|
326
299
|
* Null-Transport-Guard: ohne `SMTP_HOST`-env wird KEIN Mail-Flow
|
|
327
300
|
* verdrahtet (Routes blieben sonst 500). Eine App die einen einzelnen
|
|
@@ -636,6 +609,25 @@ function envHasMasterKek(env: Record<string, string | undefined>): boolean {
|
|
|
636
609
|
return Object.entries(env).some(([k, v]) => MASTER_KEK_VAR.test(k) && !!v);
|
|
637
610
|
}
|
|
638
611
|
|
|
612
|
+
// Prod/dev parity for ctx.notify: without this `_notifyFactory` is only wired
|
|
613
|
+
// in tests (createDeliveryTestContext), so ctx.notify is undefined at runtime
|
|
614
|
+
// and every notification silently skips. sseBroker optional (email/push don't
|
|
615
|
+
// need it, in-app SSE does); no jobRunner → queued channels send inline.
|
|
616
|
+
function buildDeliveryNotifyFactory(opts: {
|
|
617
|
+
readonly db: DbConnection;
|
|
618
|
+
readonly registry: Registry;
|
|
619
|
+
readonly sseBroker?: SseBroker;
|
|
620
|
+
}): NotifyFactory {
|
|
621
|
+
const deliveryService = createDeliveryService({
|
|
622
|
+
db: opts.db,
|
|
623
|
+
registry: opts.registry,
|
|
624
|
+
channels: collectChannels(opts.registry),
|
|
625
|
+
...(opts.sseBroker && { sseBroker: opts.sseBroker }),
|
|
626
|
+
});
|
|
627
|
+
return (user, tenantId) => (notificationType, options) =>
|
|
628
|
+
deliveryService.notify(notificationType, options, user, tenantId);
|
|
629
|
+
}
|
|
630
|
+
|
|
639
631
|
export function buildBootExtraContext(opts: {
|
|
640
632
|
readonly db: DbConnection;
|
|
641
633
|
readonly features: readonly FeatureDefinition[];
|
|
@@ -643,12 +635,21 @@ export function buildBootExtraContext(opts: {
|
|
|
643
635
|
readonly registry: Registry;
|
|
644
636
|
readonly hasAuth: boolean;
|
|
645
637
|
readonly masterKey?: MasterKeyProvider;
|
|
638
|
+
readonly sseBroker?: SseBroker;
|
|
646
639
|
}): Record<string, unknown> {
|
|
647
640
|
const hasSecretsFeature = opts.features.some((f) => f.name === SECRETS_FEATURE_NAME);
|
|
648
641
|
const wireSecrets =
|
|
649
642
|
hasSecretsFeature && (opts.masterKey !== undefined || envHasMasterKek(opts.envSource));
|
|
643
|
+
const hasDeliveryFeature = opts.features.some((f) => f.name === DELIVERY_FEATURE);
|
|
650
644
|
return {
|
|
651
645
|
textContent: createTextContentApi(opts.db),
|
|
646
|
+
...(hasDeliveryFeature && {
|
|
647
|
+
_notifyFactory: buildDeliveryNotifyFactory({
|
|
648
|
+
db: opts.db,
|
|
649
|
+
registry: opts.registry,
|
|
650
|
+
...(opts.sseBroker && { sseBroker: opts.sseBroker }),
|
|
651
|
+
}),
|
|
652
|
+
}),
|
|
652
653
|
...(wireSecrets && {
|
|
653
654
|
secrets: createSecretsContext({
|
|
654
655
|
db: opts.db,
|
|
@@ -695,27 +696,44 @@ export function resolveAuthMail<T extends AuthMailNormalizable>(
|
|
|
695
696
|
envSource: Record<string, string | undefined>,
|
|
696
697
|
): T {
|
|
697
698
|
if (!auth.mail) return auth;
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
699
|
+
// SMTP-presence gate: ohne SMTP_HOST-env wird KEIN Flow verdrahtet (Routes
|
|
700
|
+
// blieben sonst 500). Der eigentliche Mail-Versand läuft über delivery
|
|
701
|
+
// (channel-email), nicht über diesen Transport — er ist nur der Detektor
|
|
702
|
+
// "ist Mail konfiguriert?".
|
|
703
|
+
if (
|
|
704
|
+
!createSmtpTransportFromEnv(envSource, { fallbackFrom: auth.mail.from ?? "noreply@localhost" })
|
|
705
|
+
) {
|
|
706
|
+
return auth;
|
|
707
|
+
}
|
|
708
|
+
const paths = makeAuthPaths(auth.mail.paths);
|
|
709
|
+
// appName/locale fließen in alle vier Flow-Options (alle mailen via delivery).
|
|
710
|
+
const mailPresentation = {
|
|
707
711
|
...(auth.mail.appName !== undefined && { appName: auth.mail.appName }),
|
|
708
712
|
...(auth.mail.locale !== undefined && { locale: auth.mail.locale }),
|
|
709
|
-
|
|
710
|
-
emailVerificationMode: auth.mail.emailVerificationMode,
|
|
711
|
-
}),
|
|
712
|
-
});
|
|
713
|
+
};
|
|
713
714
|
return {
|
|
714
715
|
...auth,
|
|
715
|
-
passwordReset: auth.passwordReset ??
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
716
|
+
passwordReset: auth.passwordReset ?? {
|
|
717
|
+
hmacSecret,
|
|
718
|
+
appUrl: `${auth.mail.baseUrl}${paths.resetPassword}`,
|
|
719
|
+
...mailPresentation,
|
|
720
|
+
},
|
|
721
|
+
emailVerification: auth.emailVerification ?? {
|
|
722
|
+
hmacSecret,
|
|
723
|
+
appUrl: `${auth.mail.baseUrl}${paths.verifyEmail}`,
|
|
724
|
+
...(auth.mail.emailVerificationMode !== undefined && {
|
|
725
|
+
mode: auth.mail.emailVerificationMode,
|
|
726
|
+
}),
|
|
727
|
+
...mailPresentation,
|
|
728
|
+
},
|
|
729
|
+
signup: auth.signup ?? {
|
|
730
|
+
appUrl: `${auth.mail.baseUrl}${paths.signupComplete}`,
|
|
731
|
+
...mailPresentation,
|
|
732
|
+
},
|
|
733
|
+
invite: auth.invite ?? {
|
|
734
|
+
appUrl: `${auth.mail.baseUrl}${paths.inviteAccept}`,
|
|
735
|
+
...mailPresentation,
|
|
736
|
+
},
|
|
719
737
|
};
|
|
720
738
|
}
|
|
721
739
|
|
|
@@ -907,6 +925,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
907
925
|
envSource,
|
|
908
926
|
registry,
|
|
909
927
|
hasAuth: !!effectiveAuth,
|
|
928
|
+
sseBroker,
|
|
910
929
|
...(options.masterKey && { masterKey: options.masterKey }),
|
|
911
930
|
});
|
|
912
931
|
const extraContext = addConfigAccessorFactory(
|
|
@@ -979,24 +998,18 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
979
998
|
passwordReset: {
|
|
980
999
|
requestHandler: AuthHandlers.requestPasswordReset,
|
|
981
1000
|
confirmHandler: AuthHandlers.resetPassword,
|
|
982
|
-
sendResetEmail: effectiveAuth.passwordReset.sendResetEmail,
|
|
983
|
-
appResetUrl: effectiveAuth.passwordReset.appResetUrl,
|
|
984
1001
|
},
|
|
985
1002
|
}),
|
|
986
1003
|
...(effectiveAuth.emailVerification && {
|
|
987
1004
|
emailVerification: {
|
|
988
1005
|
requestHandler: AuthHandlers.requestEmailVerification,
|
|
989
1006
|
confirmHandler: AuthHandlers.verifyEmail,
|
|
990
|
-
sendVerificationEmail: effectiveAuth.emailVerification.sendVerificationEmail,
|
|
991
|
-
appVerifyUrl: effectiveAuth.emailVerification.appVerifyUrl,
|
|
992
1007
|
},
|
|
993
1008
|
}),
|
|
994
1009
|
...(effectiveAuth.signup && {
|
|
995
1010
|
signup: {
|
|
996
1011
|
requestHandler: AuthHandlers.signupRequest,
|
|
997
1012
|
confirmHandler: AuthHandlers.signupConfirm,
|
|
998
|
-
sendActivationEmail: effectiveAuth.signup.sendActivationEmail,
|
|
999
|
-
appActivationUrl: effectiveAuth.signup.appActivationUrl,
|
|
1000
1013
|
},
|
|
1001
1014
|
}),
|
|
1002
1015
|
...(effectiveAuth.invite && {
|
|
@@ -1004,8 +1017,6 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
1004
1017
|
acceptHandler: AuthHandlers.inviteAccept,
|
|
1005
1018
|
acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
|
|
1006
1019
|
signupCompleteHandler: AuthHandlers.inviteSignupComplete,
|
|
1007
|
-
sendInviteEmail: effectiveAuth.invite.sendInviteEmail,
|
|
1008
|
-
appAcceptUrl: effectiveAuth.invite.appAcceptUrl,
|
|
1009
1020
|
},
|
|
1010
1021
|
}),
|
|
1011
1022
|
},
|