@cosmicdrift/kumiko-dev-server 0.102.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.102.1",
3
+ "version": "0.104.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.102.1",
54
- "@cosmicdrift/kumiko-framework": "0.102.1",
53
+ "@cosmicdrift/kumiko-bundled-features": "0.104.0",
54
+ "@cosmicdrift/kumiko-framework": "0.104.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, and
10
- // the money-horse regression (no secrets feature → no forced KEK → no throw).
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. Die
20
- // sendResetEmail-callback (analog runProdApp's createAuthMailerConfig)
21
- // schreibt in ein lokales Array — gewollter Capture-Spy ohne vitest-
22
- // Mock-API (CLAUDE.md "Kein Mock in *.integration.ts").
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
- type CapturedReset = { email: string; resetUrl: string; expiresAt: string };
57
- type CapturedVerify = { email: string; verificationUrl: string; expiresAt: string };
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
- stack: TestStack;
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, je nach authOptionsKind variiert die
71
- // Konfiguration. Dieser Test cared explizit um die DURCHREICHE — also
72
- // dass das Wrapper-API-Object an createAuthEmailPasswordFeature ankommt.
73
- const features = composeFeatures([], {
74
- includeBundled: true,
75
- ...(authOptionsKind !== "without-auth-options" && {
76
- authOptions: {
77
- passwordReset: { hmacSecret: RESET_HMAC, tokenTtlMinutes: 15 },
78
- ...(authOptionsKind === "with-both" && {
79
- emailVerification: { hmacSecret: VERIFY_HMAC, tokenTtlMinutes: 60, mode: "off" },
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: { configResolver: createConfigResolver() },
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, { configValuesTable, tenantMembershipsTable });
144
+ await unsafePushTables(stack.db, {
145
+ configValuesTable,
146
+ tenantMembershipsTable,
147
+ notificationPreferencesTable,
148
+ });
117
149
 
118
- return { stack, resetEmails, verifyEmails };
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.resetEmails.length = 0;
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.resetEmails).toHaveLength(1);
174
- const captured = suite.resetEmails[0];
175
- if (!captured) throw new Error("no email captured");
176
- expect(captured.email).toBe("alice@example.com");
177
-
178
- // Token aus URL extrahieren — wie der echte User es täte (Mail klicken,
179
- // Browser parsed query-string). Das macht dieser Test "echter" als ein
180
- // signResetToken-Bypass: er pinst den vollen URL-zu-Handler-Roundtrip.
181
- const resetUrl = new URL(captured.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.resetEmails.length = 0;
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.verifyEmails).toHaveLength(1);
246
- const captured = suite.verifyEmails[0];
247
- if (!captured) throw new Error("no verification email captured");
248
- expect(captured.email).toBe("bob@example.com");
249
- const verifyUrl = new URL(captured.verificationUrl);
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.resetEmails.length = 0;
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.resetEmails).toHaveLength(1);
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.verifyEmails).toHaveLength(0);
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.resetEmails.length = 0;
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.resetEmails).toHaveLength(0);
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
- expect(out.passwordReset?.appResetUrl).toBe("https://app.example.com/reset-password");
32
- expect(out.emailVerification?.appVerifyUrl).toBe("https://app.example.com/verify-email");
33
- expect(out.signup?.appActivationUrl).toBe("https://app.example.com/signup/complete");
34
- expect(out.invite?.appAcceptUrl).toBe("https://app.example.com/invite/accept");
35
- expect(typeof out.passwordReset?.sendResetEmail).toBe("function");
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
- appResetUrl: "https://custom.example.com/pw",
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?.appResetUrl).toBe("https://custom.example.com/pw");
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?.appActivationUrl).toBe("https://app.example.com/signup/complete");
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?.appResetUrl).toBe("https://app.example.com/pw");
66
- expect(out.emailVerification?.appVerifyUrl).toBe("https://app.example.com/verify-email");
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
  });
@@ -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
- const reset: { -readonly [K in keyof PasswordResetOptions]: PasswordResetOptions[K] } = {
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
- const verify: { -readonly [K in keyof EmailVerificationOptions]: EmailVerificationOptions[K] } =
113
- {
114
- hmacSecret: auth.emailVerification.hmacSecret,
115
- };
116
- if (auth.emailVerification.tokenTtlMinutes !== undefined) {
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
- // Plain object statt mapped-type — SignupOptions ist type-alias auf
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
- // Plain object analog signup (gleicher type-alias-issue).
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
@@ -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
  },
@@ -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,
@@ -229,62 +233,27 @@ function makeDryRunHandle(): ProdAppHandle {
229
233
 
230
234
  /** Wrapper-API für den Password-Reset-Flow.
231
235
  *
232
- * Setup = Feature-Options (PasswordResetOptions = hmacSecret +
233
- * tokenTtlMinutes) PLUS die Mail-Side die der Wrapper an die
234
- * auth-routes-config durchreicht (sendResetEmail-callback +
235
- * appResetUrl). Apps geben EINEN Block; run{Prod,Dev}App splittet
236
- * intern auf composeFeatures(authOptions) für die Feature-Options
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
- };
236
+ * Seit der delivery-Migration trägt PasswordResetOptions selbst `appUrl`
237
+ * (+ appName/locale) und der Handler mailt via ctx.notify kein
238
+ * sendResetEmail-Callback mehr. Apps geben `auth.mail` (Convenience,
239
+ * resolveAuthMail baut die appUrl) ODER einen expliziten Block. */
240
+ export type PasswordResetSetup = PasswordResetOptions;
249
241
 
250
242
  /** Wrapper-API für den Email-Verification-Flow. Symmetrisch zu
251
- * PasswordResetSetup — extends EmailVerificationOptions + Mail-Side. */
252
- export type EmailVerificationSetup = EmailVerificationOptions & {
253
- readonly sendVerificationEmail: (args: {
254
- email: string;
255
- verificationUrl: string;
256
- expiresAt: string;
257
- }) => Promise<void>;
258
- readonly appVerifyUrl: string;
259
- };
260
-
261
- /** Wrapper-API für Magic-Link Self-Signup. Mirror der existing
262
- * PasswordResetSetup-Struktur Feature-Options (tokenTtlMinutes,
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
243
+ * PasswordResetSetup — = EmailVerificationOptions (appUrl via delivery). */
244
+ export type EmailVerificationSetup = EmailVerificationOptions;
245
+
246
+ /** Wrapper-API für Magic-Link Self-Signup. = SignupOptions (appUrl, die
247
+ * Mail geht via delivery/ctx.notify wie reset/verify). Anders als reset/
248
+ * verify gibt's KEIN hmacSecret — Signup-Tokens sind opaque random in
249
+ * Redis, nicht HMAC-signed. */
250
+ export type SignupSetup = SignupOptions;
251
+
252
+ /** Wrapper-API für Tenant-Invite Magic-Link. = InviteOptions (appUrl, die
253
+ * Invite-Mail geht via delivery wie reset/verify/signup). Drei accept-
254
+ * Branches im framework; handler-names sind hardcoded in run-prod-app aus
278
255
  * 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
- };
256
+ export type InviteSetup = InviteOptions;
288
257
 
289
258
  /** Auth-Mail-Convenience-Optionen — shared zwischen runProdApp + runDevApp.
290
259
  * Verdrahtet alle 4 Mail-Flows aus einem env-SMTP-Transport + Standard-
@@ -319,9 +288,9 @@ export type RunProdAppAuthOptions = {
319
288
  * (legacy-Verhalten, Karten­haus existing-Apps unangefasst). */
320
289
  readonly sessions?: ProdSessionsOption;
321
290
  /** Auth-Mail-Convenience: verdrahtet alle 4 Mail-Flows (passwordReset,
322
- * emailVerification, signup, invite) aus einem SMTP-Transport-aus-env +
323
- * Standard-Templates. Ersetzt das per-App hand-gerollte
324
- * `createSmtpTransportFromEnv` + `createAuthMailerConfig`-Wrapper-Trio.
291
+ * emailVerification, signup, invite) aus `auth.mail.baseUrl` + Standard-
292
+ * Pfaden. Alle vier mailen via delivery (ctx.notify) — ersetzt das per-App
293
+ * hand-gerollte `send*Email`-Callback-Wiring.
325
294
  *
326
295
  * Null-Transport-Guard: ohne `SMTP_HOST`-env wird KEIN Mail-Flow
327
296
  * verdrahtet (Routes blieben sonst 500). Eine App die einen einzelnen
@@ -636,6 +605,25 @@ function envHasMasterKek(env: Record<string, string | undefined>): boolean {
636
605
  return Object.entries(env).some(([k, v]) => MASTER_KEK_VAR.test(k) && !!v);
637
606
  }
638
607
 
608
+ // Prod/dev parity for ctx.notify: without this `_notifyFactory` is only wired
609
+ // in tests (createDeliveryTestContext), so ctx.notify is undefined at runtime
610
+ // and every notification silently skips. sseBroker optional (email/push don't
611
+ // need it, in-app SSE does); no jobRunner → queued channels send inline.
612
+ function buildDeliveryNotifyFactory(opts: {
613
+ readonly db: DbConnection;
614
+ readonly registry: Registry;
615
+ readonly sseBroker?: SseBroker;
616
+ }): NotifyFactory {
617
+ const deliveryService = createDeliveryService({
618
+ db: opts.db,
619
+ registry: opts.registry,
620
+ channels: collectChannels(opts.registry),
621
+ ...(opts.sseBroker && { sseBroker: opts.sseBroker }),
622
+ });
623
+ return (user, tenantId) => (notificationType, options) =>
624
+ deliveryService.notify(notificationType, options, user, tenantId);
625
+ }
626
+
639
627
  export function buildBootExtraContext(opts: {
640
628
  readonly db: DbConnection;
641
629
  readonly features: readonly FeatureDefinition[];
@@ -643,12 +631,21 @@ export function buildBootExtraContext(opts: {
643
631
  readonly registry: Registry;
644
632
  readonly hasAuth: boolean;
645
633
  readonly masterKey?: MasterKeyProvider;
634
+ readonly sseBroker?: SseBroker;
646
635
  }): Record<string, unknown> {
647
636
  const hasSecretsFeature = opts.features.some((f) => f.name === SECRETS_FEATURE_NAME);
648
637
  const wireSecrets =
649
638
  hasSecretsFeature && (opts.masterKey !== undefined || envHasMasterKek(opts.envSource));
639
+ const hasDeliveryFeature = opts.features.some((f) => f.name === DELIVERY_FEATURE);
650
640
  return {
651
641
  textContent: createTextContentApi(opts.db),
642
+ ...(hasDeliveryFeature && {
643
+ _notifyFactory: buildDeliveryNotifyFactory({
644
+ db: opts.db,
645
+ registry: opts.registry,
646
+ ...(opts.sseBroker && { sseBroker: opts.sseBroker }),
647
+ }),
648
+ }),
652
649
  ...(wireSecrets && {
653
650
  secrets: createSecretsContext({
654
651
  db: opts.db,
@@ -695,27 +692,44 @@ export function resolveAuthMail<T extends AuthMailNormalizable>(
695
692
  envSource: Record<string, string | undefined>,
696
693
  ): T {
697
694
  if (!auth.mail) return auth;
698
- const mailSender = createSmtpTransportFromEnv(envSource, {
699
- fallbackFrom: auth.mail.from ?? "noreply@localhost",
700
- });
701
- if (!mailSender) return auth;
702
- const mc: AuthMailerConfig = createAuthMailerConfig({
703
- mailSender,
704
- hmacSecret,
705
- baseUrl: auth.mail.baseUrl,
706
- paths: makeAuthPaths(auth.mail.paths),
695
+ // SMTP-presence gate: ohne SMTP_HOST-env wird KEIN Flow verdrahtet (Routes
696
+ // blieben sonst 500). Der eigentliche Mail-Versand läuft über delivery
697
+ // (channel-email), nicht über diesen Transport — er ist nur der Detektor
698
+ // "ist Mail konfiguriert?".
699
+ if (
700
+ !createSmtpTransportFromEnv(envSource, { fallbackFrom: auth.mail.from ?? "noreply@localhost" })
701
+ ) {
702
+ return auth;
703
+ }
704
+ const paths = makeAuthPaths(auth.mail.paths);
705
+ // appName/locale fließen in alle vier Flow-Options (alle mailen via delivery).
706
+ const mailPresentation = {
707
707
  ...(auth.mail.appName !== undefined && { appName: auth.mail.appName }),
708
708
  ...(auth.mail.locale !== undefined && { locale: auth.mail.locale }),
709
- ...(auth.mail.emailVerificationMode !== undefined && {
710
- emailVerificationMode: auth.mail.emailVerificationMode,
711
- }),
712
- });
709
+ };
713
710
  return {
714
711
  ...auth,
715
- passwordReset: auth.passwordReset ?? mc.passwordReset,
716
- emailVerification: auth.emailVerification ?? mc.emailVerification,
717
- signup: auth.signup ?? mc.signup,
718
- invite: auth.invite ?? mc.invite,
712
+ passwordReset: auth.passwordReset ?? {
713
+ hmacSecret,
714
+ appUrl: `${auth.mail.baseUrl}${paths.resetPassword}`,
715
+ ...mailPresentation,
716
+ },
717
+ emailVerification: auth.emailVerification ?? {
718
+ hmacSecret,
719
+ appUrl: `${auth.mail.baseUrl}${paths.verifyEmail}`,
720
+ ...(auth.mail.emailVerificationMode !== undefined && {
721
+ mode: auth.mail.emailVerificationMode,
722
+ }),
723
+ ...mailPresentation,
724
+ },
725
+ signup: auth.signup ?? {
726
+ appUrl: `${auth.mail.baseUrl}${paths.signupComplete}`,
727
+ ...mailPresentation,
728
+ },
729
+ invite: auth.invite ?? {
730
+ appUrl: `${auth.mail.baseUrl}${paths.inviteAccept}`,
731
+ ...mailPresentation,
732
+ },
719
733
  };
720
734
  }
721
735
 
@@ -907,6 +921,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
907
921
  envSource,
908
922
  registry,
909
923
  hasAuth: !!effectiveAuth,
924
+ sseBroker,
910
925
  ...(options.masterKey && { masterKey: options.masterKey }),
911
926
  });
912
927
  const extraContext = addConfigAccessorFactory(
@@ -979,24 +994,18 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
979
994
  passwordReset: {
980
995
  requestHandler: AuthHandlers.requestPasswordReset,
981
996
  confirmHandler: AuthHandlers.resetPassword,
982
- sendResetEmail: effectiveAuth.passwordReset.sendResetEmail,
983
- appResetUrl: effectiveAuth.passwordReset.appResetUrl,
984
997
  },
985
998
  }),
986
999
  ...(effectiveAuth.emailVerification && {
987
1000
  emailVerification: {
988
1001
  requestHandler: AuthHandlers.requestEmailVerification,
989
1002
  confirmHandler: AuthHandlers.verifyEmail,
990
- sendVerificationEmail: effectiveAuth.emailVerification.sendVerificationEmail,
991
- appVerifyUrl: effectiveAuth.emailVerification.appVerifyUrl,
992
1003
  },
993
1004
  }),
994
1005
  ...(effectiveAuth.signup && {
995
1006
  signup: {
996
1007
  requestHandler: AuthHandlers.signupRequest,
997
1008
  confirmHandler: AuthHandlers.signupConfirm,
998
- sendActivationEmail: effectiveAuth.signup.sendActivationEmail,
999
- appActivationUrl: effectiveAuth.signup.appActivationUrl,
1000
1009
  },
1001
1010
  }),
1002
1011
  ...(effectiveAuth.invite && {
@@ -1004,8 +1013,6 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1004
1013
  acceptHandler: AuthHandlers.inviteAccept,
1005
1014
  acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
1006
1015
  signupCompleteHandler: AuthHandlers.inviteSignupComplete,
1007
- sendInviteEmail: effectiveAuth.invite.sendInviteEmail,
1008
- appAcceptUrl: effectiveAuth.invite.appAcceptUrl,
1009
1016
  },
1010
1017
  }),
1011
1018
  },