@cosmicdrift/kumiko-bundled-features 0.212.0 → 0.213.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.212.0",
3
+ "version": "0.213.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -126,12 +126,12 @@
126
126
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
127
127
  },
128
128
  "dependencies": {
129
- "@cosmicdrift/kumiko-dispatcher-live": "0.212.0",
130
- "@cosmicdrift/kumiko-framework": "0.212.0",
131
- "@cosmicdrift/kumiko-headless": "0.212.0",
132
- "@cosmicdrift/kumiko-renderer": "0.212.0",
133
- "@cosmicdrift/kumiko-renderer-web": "0.212.0",
134
- "@cosmicdrift/kumiko-types": "0.212.0",
129
+ "@cosmicdrift/kumiko-dispatcher-live": "0.213.0",
130
+ "@cosmicdrift/kumiko-framework": "0.213.0",
131
+ "@cosmicdrift/kumiko-headless": "0.213.0",
132
+ "@cosmicdrift/kumiko-renderer": "0.213.0",
133
+ "@cosmicdrift/kumiko-renderer-web": "0.213.0",
134
+ "@cosmicdrift/kumiko-types": "0.213.0",
135
135
  "@mollie/api-client": "^4.5.0",
136
136
  "@node-rs/argon2": "^2.0.2",
137
137
  "@types/mailparser": "^3.4.6",
@@ -160,6 +160,6 @@
160
160
  "devDependencies": {
161
161
  "@testing-library/user-event": "^14.6.1",
162
162
  "@types/qrcode": "^1.5.5",
163
- "@cosmicdrift/kumiko-locale-de": "0.212.0"
163
+ "@cosmicdrift/kumiko-locale-de": "0.213.0"
164
164
  }
165
165
  }
@@ -3,6 +3,7 @@ import { randomBytes } from "node:crypto";
3
3
  import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
4
4
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
5
5
  import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
6
7
  import {
7
8
  setupTestStack,
8
9
  type TestStack,
@@ -11,6 +12,7 @@ import {
11
12
  unsafePushTables,
12
13
  } from "@cosmicdrift/kumiko-framework/stack";
13
14
  import { createTestEnvelopeCipher, seedRow } from "@cosmicdrift/kumiko-framework/testing";
15
+ import { localeDeBundle } from "@cosmicdrift/kumiko-locale-de";
14
16
  import { Temporal } from "temporal-polyfill";
15
17
  import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
16
18
  import { createConfigFeature } from "../../config";
@@ -39,6 +41,13 @@ import { signResetToken } from "../reset-token";
39
41
  // directly (no jobRunner in the test stack → inline send).
40
42
  const emailTransport = createInMemoryTransport();
41
43
 
44
+ // Kept in sync with LOCALE_HEADER_NAME in api-constants.ts by hand — that
45
+ // constant is a framework-internal implementation detail, not exported from
46
+ // the public /api barrel (same as TENANT_HEADER_NAME).
47
+ const LOCALE_HEADER_NAME = "X-Locale";
48
+
49
+ registerMailTranslations("de", localeDeBundle);
50
+
42
51
  // Records the userId every time the sessions feature's auto-revoke hook
43
52
  // fires after a password change. The session-revoke tests assert on this
44
53
  // list — we don't need a full session store, just proof the hook fired.
@@ -146,8 +155,12 @@ async function seedUser(opts: {
146
155
  return { id: created.id, tenantId };
147
156
  }
148
157
 
149
- async function post(path: string, body: unknown): Promise<Response> {
150
- return stack.http.raw("POST", path, body);
158
+ async function post(
159
+ path: string,
160
+ body: unknown,
161
+ headers?: Record<string, string>,
162
+ ): Promise<Response> {
163
+ return stack.http.raw("POST", path, body, headers);
151
164
  }
152
165
 
153
166
  // --- request-password-reset -----------------------------------------------
@@ -182,6 +195,22 @@ describe("POST /auth/request-password-reset", () => {
182
195
  expect(await res.json()).toEqual({ isSuccess: true });
183
196
  expect(emailTransport.sent).toHaveLength(0);
184
197
  });
198
+
199
+ test("X-Locale: de → the reset mail is rendered in German, same as signup", async () => {
200
+ await seedUser({ email: "locale-de@example.com", password: "initial-pw!" });
201
+
202
+ const res = await post(
203
+ "/api/auth/request-password-reset",
204
+ { email: "locale-de@example.com" },
205
+ { [LOCALE_HEADER_NAME]: "de" },
206
+ );
207
+
208
+ expect(res.status).toBe(200);
209
+ expect(emailTransport.sent).toHaveLength(1);
210
+ const sent = emailTransport.sent[0];
211
+ if (!sent) throw new Error("no email sent");
212
+ expect(sent.subject).toContain("Passwort zurücksetzen");
213
+ });
185
214
  });
186
215
 
187
216
  // --- reset-password --------------------------------------------------------
@@ -29,12 +29,14 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
29
29
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
30
30
  import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
31
31
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
32
+ import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
32
33
  import {
33
34
  setupTestStack,
34
35
  type TestStack,
35
36
  unsafeCreateEntityTable,
36
37
  unsafePushTables,
37
38
  } from "@cosmicdrift/kumiko-framework/stack";
39
+ import { localeDeBundle } from "@cosmicdrift/kumiko-locale-de";
38
40
  import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
39
41
  import { createConfigFeature } from "../../config";
40
42
  import { createConfigResolver } from "../../config/resolver";
@@ -96,6 +98,13 @@ const tenantMembershipHookVendorFeature = defineFeature(
96
98
 
97
99
  const APP_ACTIVATION_URL = "https://app.example.com/signup/complete";
98
100
 
101
+ // Kept in sync with LOCALE_HEADER_NAME in api-constants.ts by hand — that
102
+ // constant is a framework-internal implementation detail, not exported from
103
+ // the public /api barrel (same as TENANT_HEADER_NAME).
104
+ const LOCALE_HEADER_NAME = "X-Locale";
105
+
106
+ registerMailTranslations("de", localeDeBundle);
107
+
99
108
  // Activation mails now go through delivery (ctx.notify → channel-email). The
100
109
  // in-memory transport captures what would be sent; route:{email} delivers
101
110
  // directly (no jobRunner in the test stack → inline send).
@@ -177,8 +186,11 @@ beforeEach(async () => {
177
186
  if (allKeys.length > 0) await stack.redis.redis.del(...allKeys);
178
187
  });
179
188
 
180
- async function postSignupRequest(email: string): Promise<Response> {
181
- return stack.http.raw("POST", "/api/auth/signup-request", { email });
189
+ async function postSignupRequest(
190
+ email: string,
191
+ headers?: Record<string, string>,
192
+ ): Promise<Response> {
193
+ return stack.http.raw("POST", "/api/auth/signup-request", { email }, headers);
182
194
  }
183
195
 
184
196
  async function postSignupConfirm(token: string, password: string): Promise<Response> {
@@ -425,3 +437,164 @@ describe("POST /api/auth/signup-confirm", () => {
425
437
  expect(userRows).toHaveLength(0);
426
438
  });
427
439
  });
440
+
441
+ describe("POST /api/auth/signup-request — mail locale follows the active browser language", () => {
442
+ // The stack config sets no `signup.locale` (see beforeAll above) — this
443
+ // isolates ctx.locale as the only possible source, so a German subject
444
+ // here can only come from the X-Locale header, never from opts.locale.
445
+ test("X-Locale: de → the activation mail is rendered in German", async () => {
446
+ const res = await postSignupRequest("locale-de@example.com", { [LOCALE_HEADER_NAME]: "de" });
447
+ expect(res.status).toBe(200);
448
+ expect(emailTransport.sent).toHaveLength(1);
449
+ const sent = emailTransport.sent[0];
450
+ if (!sent) throw new Error("no mail sent");
451
+ expect(sent.subject).toContain("Account aktivieren");
452
+ });
453
+
454
+ test("no X-Locale header → the activation mail falls back to English", async () => {
455
+ const res = await postSignupRequest("locale-default@example.com");
456
+ expect(res.status).toBe(200);
457
+ expect(emailTransport.sent).toHaveLength(1);
458
+ const sent = emailTransport.sent[0];
459
+ if (!sent) throw new Error("no mail sent");
460
+ expect(sent.subject).toContain("Activate your account");
461
+ });
462
+ });
463
+
464
+ describe("POST /api/auth/signup-request — opts.locale stays a real fallback", () => {
465
+ // Regression guard: ctx.locale is always resolved (never undefined), so a
466
+ // naive `ctx.locale ?? opts.locale` would make opts.locale unreachable and
467
+ // silently drop every app's configured mail language the moment this PR
468
+ // ships. This stack configures signup.locale statically (no defaultLocale
469
+ // on the app context either) — a request with no X-Locale header must
470
+ // still land in the handler's configured language, not "en".
471
+ const optsLocaleTransport = createInMemoryTransport();
472
+ let optsLocaleStack: TestStack;
473
+
474
+ beforeAll(async () => {
475
+ optsLocaleStack = await setupTestStack({
476
+ features: [
477
+ createConfigFeature(),
478
+ createUserFeature(),
479
+ createTenantFeature(),
480
+ createTemplateResolverFeature(),
481
+ createRendererFoundationFeature(),
482
+ createDeliveryFeature(),
483
+ createRendererSimpleFeature(),
484
+ createChannelEmailFeature({
485
+ transport: optsLocaleTransport,
486
+ renderer: simpleRenderer,
487
+ resolveEmail: async () => "unused@test.local",
488
+ }),
489
+ createAuthEmailPasswordFeature({
490
+ signup: { tokenTtlMinutes: 60, appUrl: APP_ACTIVATION_URL, locale: "de" },
491
+ }),
492
+ ],
493
+ extraContext: (deps) => ({
494
+ ...createDeliveryTestContext(deps),
495
+ configResolver: createConfigResolver(),
496
+ }),
497
+ authConfig: {
498
+ membershipQuery: "tenant:query:memberships",
499
+ loginHandler: AuthHandlers.login,
500
+ signup: {
501
+ requestHandler: AuthHandlers.signupRequest,
502
+ confirmHandler: AuthHandlers.signupConfirm,
503
+ },
504
+ },
505
+ });
506
+ await unsafeCreateEntityTable(optsLocaleStack.db, userEntity);
507
+ await unsafeCreateEntityTable(optsLocaleStack.db, tenantEntity);
508
+ await unsafePushTables(optsLocaleStack.db, {
509
+ configValuesTable,
510
+ tenantMembershipsTable,
511
+ notificationPreferencesTable,
512
+ });
513
+ });
514
+
515
+ afterAll(async () => {
516
+ await optsLocaleStack.cleanup();
517
+ });
518
+
519
+ test("no X-Locale header → falls back to the handler's configured opts.locale, not English", async () => {
520
+ const res = await optsLocaleStack.http.raw("POST", "/api/auth/signup-request", {
521
+ email: "opts-locale@example.com",
522
+ });
523
+ expect(res.status).toBe(200);
524
+ expect(optsLocaleTransport.sent).toHaveLength(1);
525
+ const sent = optsLocaleTransport.sent[0];
526
+ if (!sent) throw new Error("no mail sent");
527
+ expect(sent.subject).toContain("Account aktivieren");
528
+ });
529
+ });
530
+
531
+ describe("POST /api/auth/signup-request — appUrl as a function picks the activation link's locale", () => {
532
+ // This is the actual mechanism the whole PR exists for: an app with
533
+ // language-in-path routing (/de/activate, /en/activate, ...) passes a
534
+ // function instead of a plain string, and the resolved locale must land
535
+ // in the link the user actually clicks — not just the mail's language.
536
+ const appUrlTransport = createInMemoryTransport();
537
+ let appUrlStack: TestStack;
538
+
539
+ beforeAll(async () => {
540
+ appUrlStack = await setupTestStack({
541
+ features: [
542
+ createConfigFeature(),
543
+ createUserFeature(),
544
+ createTenantFeature(),
545
+ createTemplateResolverFeature(),
546
+ createRendererFoundationFeature(),
547
+ createDeliveryFeature(),
548
+ createRendererSimpleFeature(),
549
+ createChannelEmailFeature({
550
+ transport: appUrlTransport,
551
+ renderer: simpleRenderer,
552
+ resolveEmail: async () => "unused@test.local",
553
+ }),
554
+ createAuthEmailPasswordFeature({
555
+ signup: {
556
+ tokenTtlMinutes: 60,
557
+ appUrl: (locale) => `https://app.example.com/${locale}/activate`,
558
+ },
559
+ }),
560
+ ],
561
+ extraContext: (deps) => ({
562
+ ...createDeliveryTestContext(deps),
563
+ configResolver: createConfigResolver(),
564
+ }),
565
+ authConfig: {
566
+ membershipQuery: "tenant:query:memberships",
567
+ loginHandler: AuthHandlers.login,
568
+ signup: {
569
+ requestHandler: AuthHandlers.signupRequest,
570
+ confirmHandler: AuthHandlers.signupConfirm,
571
+ },
572
+ },
573
+ });
574
+ await unsafeCreateEntityTable(appUrlStack.db, userEntity);
575
+ await unsafeCreateEntityTable(appUrlStack.db, tenantEntity);
576
+ await unsafePushTables(appUrlStack.db, {
577
+ configValuesTable,
578
+ tenantMembershipsTable,
579
+ notificationPreferencesTable,
580
+ });
581
+ });
582
+
583
+ afterAll(async () => {
584
+ await appUrlStack.cleanup();
585
+ });
586
+
587
+ test("X-Locale: de → the activation link in the sent mail points at /de/activate", async () => {
588
+ const res = await appUrlStack.http.raw(
589
+ "POST",
590
+ "/api/auth/signup-request",
591
+ { email: "appurl-de@example.com" },
592
+ { [LOCALE_HEADER_NAME]: "de" },
593
+ );
594
+ expect(res.status).toBe(200);
595
+ expect(appUrlTransport.sent).toHaveLength(1);
596
+ const sent = appUrlTransport.sent[0];
597
+ if (!sent) throw new Error("no mail sent");
598
+ expect(sent.html).toContain("https://app.example.com/de/activate?token=");
599
+ });
600
+ });
@@ -64,8 +64,10 @@ export type PasswordResetOptions = {
64
64
  readonly tokenTtlMinutes?: number;
65
65
  // App page that receives the magic-link; the handler appends `?token=…` and
66
66
  // sends the mail via delivery (ctx.notify). No sendResetEmail callback — the
67
- // app mounts `delivery` + a mail channel instead.
68
- readonly appUrl: string;
67
+ // app mounts `delivery` + a mail channel instead. Apps with language-in-path
68
+ // routing may pass a function to pick the right page for the resolved
69
+ // locale instead of a plain string.
70
+ readonly appUrl: string | ((locale: string) => string);
69
71
  readonly appName?: string;
70
72
  readonly locale?: AuthMailLocale;
71
73
  };
@@ -80,8 +82,10 @@ export type EmailVerificationOptions = {
80
82
  readonly tokenTtlMinutes?: number;
81
83
  readonly mode?: "strict" | "off";
82
84
  // App page that receives the magic-link; the handler appends `?token=…` and
83
- // sends via delivery (ctx.notify). No sendVerificationEmail callback.
84
- readonly appUrl: string;
85
+ // sends via delivery (ctx.notify). No sendVerificationEmail callback. Apps
86
+ // with language-in-path routing may pass a function to pick the right page
87
+ // for the resolved locale instead of a plain string.
88
+ readonly appUrl: string | ((locale: string) => string);
85
89
  readonly appName?: string;
86
90
  readonly locale?: AuthMailLocale;
87
91
  };
@@ -17,7 +17,7 @@
17
17
  // erlaubt new-user-signup mit dem Token. Keine Enumeration durchs
18
18
  // invite-create.
19
19
 
20
- import { generateToken } from "@cosmicdrift/kumiko-framework/api";
20
+ import { generateToken, requestContext } from "@cosmicdrift/kumiko-framework/api";
21
21
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
22
22
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
23
23
  import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
@@ -62,9 +62,14 @@ export type InviteCreateOptions = {
62
62
  /** TTL für den Activation-Token. Default 7 Tage. */
63
63
  readonly tokenTtlMinutes?: number;
64
64
  /** App page that receives the magic-link; the handler appends `?token=…`
65
- * and dispatches the invite mail via delivery (ctx.notify). */
66
- readonly appUrl: string;
65
+ * and dispatches the invite mail via delivery (ctx.notify). Apps with
66
+ * language-in-path routing pass a function to pick the right page for
67
+ * the resolved locale; everyone else keeps a plain string. */
68
+ readonly appUrl: string | ((locale: string) => string);
67
69
  readonly appName?: string;
70
+ /** Static fallback mail locale, used only when the request itself
71
+ * carries no locale signal (no X-Locale header, no usable
72
+ * Accept-Language) — see the locale resolution in the handler below. */
68
73
  readonly locale?: AuthMailLocale;
69
74
  // Opt-in role-hierarchy gate. Roles are app-defined strings, not a framework
70
75
  // concept, so the hierarchy itself must live in the app — this hook lets it
@@ -154,6 +159,11 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
154
159
 
155
160
  await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
156
161
 
162
+ // Same precedence as signup/reset/verify: the inviting admin's active
163
+ // browser language wins when the request carries one, else the
164
+ // handler's static opts.locale, else ctx.locale's own default.
165
+ const locale = requestContext.get()?.locale ?? opts.locale ?? ctx.locale;
166
+
157
167
  await dispatchMagicLinkMail(
158
168
  ctx.notify,
159
169
  {
@@ -168,7 +178,7 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
168
178
  token,
169
179
  expiresAt: expiresAt.toString(),
170
180
  ...(opts.appName !== undefined && { appName: opts.appName }),
171
- ...(opts.locale !== undefined && { locale: opts.locale }),
181
+ locale,
172
182
  },
173
183
  );
174
184
 
@@ -1,27 +1,27 @@
1
- // Magic-Link-Signup, Step 1 (request).
1
+ // Magic-link signup, step 1 (request).
2
2
  //
3
- // User gibt Email einwir minten einen opaken Random-Token, speichern
4
- // ihn bidirektional in Redis (tokenemail), und schicken die Activation-Mail
5
- // via delivery (ctx.notify) — wie reset/verify. Anders als die: HIER kein
6
- // userId-Lookup und kein HMAC-signing (es gäbe kein Subjectim Normalfall
7
- // existiert der User noch nicht). Ob die Email bereits ein Konto hat,
8
- // entscheidet bewusst der Confirm-Schritt, nicht dieser.
3
+ // User enters an emailwe mint an opaque random token, store it
4
+ // bidirectionally in Redis (token<->email), and send the activation mail
5
+ // via delivery (ctx.notify) — same as reset/verify. Unlike those: NO
6
+ // userId lookup and NO HMAC signing here (there'd be no subjectnormally
7
+ // the user doesn't exist yet). Whether the email already has an account is
8
+ // deliberately decided by the confirm step, not this one.
9
9
  //
10
10
  // Resend: if a token is still live for this email, we invalidate it and
11
11
  // mint a fresh one — the user gets a second mail with a NEW activation
12
12
  // link, and the first link stops working. Deliberate: at most one live
13
13
  // signup token per email at any time (see signup-token-store.ts).
14
14
  //
15
- // Always-200 (enumeration-safe): das Response sieht für jede Email gleich
16
- // aus, egal ob sie schon registriert ist oder nicht. Eine Email KANN bereits
17
- // ein Konto haben (Seeding oder früherer Signup) — die Sperre dagegen sitzt
18
- // bewusst im Confirm-Schritt (#365): signup-confirm lehnt eine bereits
19
- // registrierte Email ab statt den bestehenden User wiederzuverwenden. Hier
20
- // bleibt's always-200 + Resend-idempotent, damit der Request-Pfad nichts
21
- // leakt; ein request-seitiges Unterdrücken des Links wäre Defense-in-depth,
22
- // aber mit Enumeration-Risiko (separat).
15
+ // Always-200 (enumeration-safe): the response looks the same for every
16
+ // email, whether it's already registered or not. An email CAN already have
17
+ // an account (seeding or an earlier signup) — the actual gate sits
18
+ // deliberately in the confirm step (#365): signup-confirm rejects an
19
+ // already-registered email instead of reusing the existing user. Here it
20
+ // stays always-200 + resend-idempotent so the request path leaks nothing;
21
+ // suppressing the link on the request side would be defense-in-depth, but
22
+ // with an enumeration risk of its own (separate concern).
23
23
 
24
- import { generateToken } from "@cosmicdrift/kumiko-framework/api";
24
+ import { generateToken, requestContext } from "@cosmicdrift/kumiko-framework/api";
25
25
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
26
26
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
27
27
  import { Temporal } from "temporal-polyfill";
@@ -53,13 +53,18 @@ export type SignupRequestData =
53
53
  | { readonly kind: "no-op" };
54
54
 
55
55
  export type SignupRequestOptions = {
56
- /** TTL für den Activation-Token. Default 24 h lang genug damit User
57
- * "morgen aktivieren" können ohne Resend-Spam. */
56
+ /** TTL for the activation token. Default 24hlong enough that users
57
+ * can "activate tomorrow" without resend spam. */
58
58
  readonly tokenTtlMinutes?: number;
59
59
  /** App page that receives the magic-link; the handler appends `?token=…`
60
- * and dispatches the activation mail via delivery (ctx.notify). */
61
- readonly appUrl: string;
60
+ * and dispatches the activation mail via delivery (ctx.notify). Apps
61
+ * with language-in-path routing pass a function to pick the right page
62
+ * for the resolved locale; everyone else keeps a plain string. */
63
+ readonly appUrl: string | ((locale: string) => string);
62
64
  readonly appName?: string;
65
+ /** Static fallback mail locale for this handler instance, used only when
66
+ * the request itself carries no locale signal (no X-Locale header, no
67
+ * usable Accept-Language) — see the locale resolution below. */
63
68
  readonly locale?: AuthMailLocale;
64
69
  };
65
70
 
@@ -101,22 +106,21 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
101
106
  );
102
107
  }
103
108
 
104
- // Email-Normalisierung lebt im Store (signup-token-store). Der
105
- // Handler reicht die raw email durcheine Quelle, kein Drift
106
- // zwischen Lookup-Pfaden die unterschiedlich (oder gar nicht)
107
- // lowercased haben.
109
+ // Email normalization lives in the store (signup-token-store). The
110
+ // handler passes the raw email throughone source, no drift
111
+ // between lookup paths that lowercase differently (or not at all).
108
112
  const email = event.payload.email;
109
113
 
110
114
  // At most one live signup token per email: invalidate whatever's
111
115
  // there before minting the new one (see signup-token-store.ts).
112
116
  await invalidateExistingSignupToken(ctx.redis, email);
113
117
  // 32 random bytes = 256 bits unguessable randomness, base64url
114
- // encoded zu 43 chars. Math.random war früher ein Bug:
115
- // xorshift128+ hat ~128 Bit State der nach ~5 beobachteten
116
- // Outputs rekonstruierbar istAngreifer könnte eigene
117
- // signup-requests triggern und die Tokens fremder User
118
- // vorhersagen. generateToken nutzt randomBytes aus node:crypto,
119
- // dieselbe Quelle wie CSRF/Session-Tokens.
118
+ // encoded to 43 chars. Math.random used to be a bug here:
119
+ // xorshift128+ has ~128 bits of state that's reconstructible after
120
+ // ~5 observed outputsan attacker could trigger their own
121
+ // signup-requests and predict other users' tokens. generateToken
122
+ // uses randomBytes from node:crypto, the same source as CSRF/
123
+ // session tokens.
120
124
  const token = generateToken();
121
125
 
122
126
  const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
@@ -124,11 +128,21 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
124
128
 
125
129
  await storeSignupToken(ctx.redis, { email, token, ttlSeconds });
126
130
 
127
- // normalizeEmail aus dem Storeeine Quelle für die Normalisierungs-
128
- // Verantwortung; delivery-Empfänger + Lookup-Pfad kriegen konsistent
129
- // das gleiche Format.
131
+ // normalizeEmail from the storeone source of truth for
132
+ // normalization; the delivery recipient + lookup path consistently
133
+ // get the same format.
130
134
  const normalizedEmail = normalizeEmail(email);
131
135
 
136
+ // ctx.locale always resolves to something (falls back to the app's
137
+ // boot default, then "en" — dispatch-shared.ts), so it can't tell us
138
+ // whether THIS request actually carried a locale signal. Read the raw
139
+ // request-layer value instead: present → the browser's active
140
+ // language wins over opts.locale (this handler's static config);
141
+ // absent → opts.locale is the real, still-relevant fallback
142
+ // (backwards compatibility for callers that configured it and never
143
+ // send X-Locale), then ctx.locale's own resolved default.
144
+ const locale = requestContext.get()?.locale ?? opts.locale ?? ctx.locale;
145
+
132
146
  await dispatchMagicLinkMail(
133
147
  ctx.notify,
134
148
  {
@@ -142,7 +156,7 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
142
156
  token,
143
157
  expiresAt: expiresAtIso,
144
158
  ...(opts.appName !== undefined && { appName: opts.appName }),
145
- ...(opts.locale !== undefined && { locale: opts.locale }),
159
+ locale,
146
160
  },
147
161
  );
148
162
 
@@ -1,5 +1,6 @@
1
1
  // Shared factory for the request-side of out-of-band token flows
2
- // (password-reset, email-verification). Both follow the same shape:
2
+ // (password-reset, email-verification, account-unlock). All follow the
3
+ // same shape:
3
4
  //
4
5
  // POST email
5
6
  // → resolve user (system-scoped query)
@@ -11,6 +12,7 @@
11
12
  // default TTL, extra skip condition) + two error codes — encoded on the
12
13
  // spec rather than duplicated across two near-identical handler bodies.
13
14
 
15
+ import { requestContext } from "@cosmicdrift/kumiko-framework/api";
14
16
  import { createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
15
17
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
16
18
  import type { Temporal } from "temporal-polyfill";
@@ -70,9 +72,14 @@ export type TokenRequestSpec<TName extends string, TSuccessKind extends string>
70
72
  export type TokenRequestOptions = {
71
73
  readonly hmacSecret: string;
72
74
  readonly tokenTtlMinutes?: number;
73
- // App page that receives the magic-link; the handler appends `?token=…`.
74
- readonly appUrl: string;
75
+ /** App page that receives the magic-link; the handler appends `?token=…`.
76
+ * Apps with language-in-path routing pass a function to pick the right
77
+ * page for the resolved locale; everyone else keeps a plain string. */
78
+ readonly appUrl: string | ((locale: string) => string);
75
79
  readonly appName?: string;
80
+ /** Static fallback mail locale, used only when the request itself
81
+ * carries no locale signal (no X-Locale header, no usable
82
+ * Accept-Language) — see the locale resolution in the handler below. */
76
83
  readonly locale?: AuthMailLocale;
77
84
  };
78
85
 
@@ -117,6 +124,15 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
117
124
 
118
125
  const { token, expiresAt } = spec.sign(user.id, ttl, opts.hmacSecret);
119
126
 
127
+ // ctx.locale always resolves to something (falls back to the app's
128
+ // boot default, then "en"), so it can't tell us whether THIS request
129
+ // actually carried a locale signal. Read the raw request-layer value
130
+ // instead: present → the requester's active language wins over
131
+ // opts.locale (this handler's static config); absent → opts.locale
132
+ // is the real, still-relevant fallback, then ctx.locale's own
133
+ // resolved default.
134
+ const locale = requestContext.get()?.locale ?? opts.locale ?? ctx.locale;
135
+
120
136
  await dispatchMagicLinkMail(
121
137
  ctx.notify,
122
138
  {
@@ -132,7 +148,7 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
132
148
  token,
133
149
  expiresAt: expiresAt.toString(),
134
150
  ...(opts.appName !== undefined && { appName: opts.appName }),
135
- ...(opts.locale !== undefined && { locale: opts.locale }),
151
+ locale,
136
152
  },
137
153
  );
138
154
 
@@ -115,6 +115,8 @@ export const defaultTranslations: TranslationsByLocale = {
115
115
  "auth.signupComplete.submitting": "…",
116
116
  "auth.signupComplete.missingToken":
117
117
  "Activation link is missing a token. Please request a new one.",
118
+ "auth.signupComplete.activated": "Your account is active and you're signed in.",
119
+ "auth.signupComplete.continue": "Continue",
118
120
  "auth.inviteAccept.title": "Accept invitation",
119
121
  "auth.inviteAccept.intro": "You've been invited to a workspace. Click 'Accept' to join.",
120
122
  "auth.inviteAccept.loggedInAs": "Signed in as {email}",
@@ -21,15 +21,23 @@ export type MagicLinkMailSpec = {
21
21
 
22
22
  // Per-request values: the recipient + the app page that receives the token, plus
23
23
  // optional presentation. appUrl is the bare page URL; the token is appended here.
24
+ // appUrl as a function lets apps with language-in-path routing (/de/activate,
25
+ // /en/activate) pick the right page for `locale` — the mail is the only
26
+ // channel that survives a device switch (signup on desktop, open on mobile),
27
+ // so this is the last point where the language can still reach the link.
24
28
  export type MagicLinkMailParams = {
25
29
  readonly email: string;
26
- readonly appUrl: string;
30
+ readonly appUrl: string | ((locale: string) => string);
27
31
  readonly token: string;
28
32
  readonly expiresAt: string;
29
33
  readonly appName?: string;
30
34
  readonly locale?: AuthMailLocale;
31
35
  };
32
36
 
37
+ function resolveAppUrl(appUrl: string | ((locale: string) => string), locale: string): string {
38
+ return typeof appUrl === "function" ? appUrl(locale) : appUrl;
39
+ }
40
+
33
41
  function appendToken(appUrl: string, token: string): string {
34
42
  const sep = appUrl.includes("?") ? "&" : "?";
35
43
  return `${appUrl}${sep}token=${encodeURIComponent(token)}`;
@@ -48,8 +56,9 @@ export async function dispatchMagicLinkMail(
48
56
  message: `${spec.handlerName}: ctx.notify unavailable — the delivery feature must be mounted`,
49
57
  });
50
58
  }
59
+ const locale = params.locale ?? "en";
51
60
  const content = spec.renderContent({
52
- url: appendToken(params.appUrl, params.token),
61
+ url: appendToken(resolveAppUrl(params.appUrl, locale), params.token),
53
62
  expiresAt: params.expiresAt,
54
63
  ...(params.locale !== undefined && { locale: params.locale }),
55
64
  ...(params.appName !== undefined && { appName: params.appName }),
@@ -57,12 +57,10 @@ describe("SignupCompleteScreen", () => {
57
57
  expect(fetchMock).not.toHaveBeenCalled();
58
58
  });
59
59
 
60
- test("happy path: gültiges Passwort → signup-confirm fetch + location.assign", async () => {
61
- const assigned: string[] = [];
60
+ test("happy path: gültiges Passwort → Bestätigung statt sofortigem Redirect, Weiter-Button trifft resolveLoggedInHref-Ziel", async () => {
61
+ const assignMock = mock((_url: string | URL): void => {});
62
62
  const assignOrig = window.location.assign.bind(window.location);
63
- window.location.assign = ((url: string | URL) => {
64
- assigned.push(String(url));
65
- }) as typeof window.location.assign;
63
+ window.location.assign = assignMock as typeof window.location.assign;
66
64
 
67
65
  const fetchMock = mock(
68
66
  async () =>
@@ -94,8 +92,16 @@ describe("SignupCompleteScreen", () => {
94
92
  body: JSON.stringify({ token: "abc-token", password: "validpass1" }),
95
93
  }),
96
94
  );
97
- expect(assigned).toEqual(["/acme/"]);
95
+ expect(screen.getByText("Your account is active and you're signed in.")).toBeTruthy();
98
96
  });
97
+
98
+ // No automatic redirect: the form is gone, but nothing navigated us away.
99
+ expect(assignMock).not.toHaveBeenCalled();
100
+
101
+ // Continue button targets exactly what resolveLoggedInHref computes from
102
+ // the response's tenantKey, via the app-supplied function-form prop.
103
+ const continueLink = screen.getByRole("link", { name: "Continue" }) as HTMLAnchorElement;
104
+ expect(continueLink.getAttribute("href")).toBe("/acme/");
99
105
  } finally {
100
106
  window.location.assign = assignOrig;
101
107
  }
@@ -130,5 +136,8 @@ describe("SignupCompleteScreen", () => {
130
136
  await waitFor(() => {
131
137
  expect(screen.getByRole("alert").textContent).toMatch(/invalid|expired/i);
132
138
  });
139
+ // Error path stays on the form — no confirmation, no continue link.
140
+ expect(screen.queryByRole("link", { name: "Continue" })).toBeNull();
141
+ expect(document.getElementById("signup-password")).toBeTruthy();
133
142
  });
134
143
  });
@@ -1,19 +1,19 @@
1
1
  // @runtime client
2
2
  // SignupCompleteScreen — Magic-Link-Self-Signup, Step 2.
3
3
  //
4
- // Liest `?token=...` aus der URL, zeigt Form mit Password + Confirm.
5
- // Submit triggert /api/auth/signup-confirm — bei Erfolg setzt der
6
- // Server JWT + Cookies (Auto-Login!) und liefert tenantKey für den
7
- // Post-Signup-Redirect.
4
+ // Reads `?token=...` from the URL, shows a form with password + confirm.
5
+ // Submit posts to /api/auth/signup-confirm — on success the server sets
6
+ // JWT + cookies (auto-login!) and returns the tenantKey.
8
7
  //
9
- // Token-Quelle ist read-once via useUrlToken: liest `?token=...` beim
10
- // Mount und scrubbt den Param danach aus der URL (#774). Apps die einen
11
- // server-injected Token nutzen, reichen `token` als Prop durch.
8
+ // Token source is read-once via useUrlToken: reads `?token=...` on mount
9
+ // and scrubs the param from the URL afterwards (#774). Apps that inject a
10
+ // server-side token pass `token` as a prop instead.
12
11
  //
13
- // Nach success: redirect via window.location.assign zu loggedInHref.
14
- // Default-Pattern ist "/<tenantKey>/" die App reicht ein Template
15
- // rein. Default-Template "/" wäre auch valide (App hat dann eigene
16
- // Routing-Logik die den eingeloggten User zur richtigen Page schickt).
12
+ // On success: shows a confirmation (account active, signed in) with a
13
+ // button to loggedInHref, instead of navigating away immediately — the
14
+ // server already logged the user in via cookies, but a silent redirect
15
+ // leaves no signal that activation worked. Default pattern is "/" — apps
16
+ // with multi-tenant routing pass `(data) => "/" + data.tenantKey + "/"`.
17
17
 
18
18
  import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
19
19
  import { type FormEvent, type ReactNode, useState } from "react";
@@ -50,6 +50,7 @@ export function SignupCompleteScreen({
50
50
  const [confirmPassword, setConfirmPassword] = useState("");
51
51
  const [submitting, setSubmitting] = useState(false);
52
52
  const [error, setError] = useState<string | null>(null);
53
+ const [continueHref, setContinueHref] = useState<string | null>(null);
53
54
 
54
55
  const doSubmit = async (): Promise<void> => {
55
56
  setError(null);
@@ -66,9 +67,10 @@ export function SignupCompleteScreen({
66
67
  const res = await confirmSignup(token, password);
67
68
  setSubmitting(false);
68
69
  if (res.ok) {
69
- // Auto-Login: Cookies sind via Set-Cookie schon im Browser. Wir
70
- // schicken den User direkt zur eingeloggten Page.
71
- window.location.assign(resolveLoggedInHref(loggedInHref, res.data.tenantKey));
70
+ // Cookies are already set (auto-login). Show a confirmation with an
71
+ // explicit continue button instead of navigating away silently —
72
+ // the user otherwise gets no signal that activation worked.
73
+ setContinueHref(resolveLoggedInHref(loggedInHref, res.data.tenantKey));
72
74
  return;
73
75
  }
74
76
  if (res.error.reason === "invalid_signup_token") {
@@ -104,6 +106,19 @@ export function SignupCompleteScreen({
104
106
  );
105
107
  }
106
108
 
109
+ if (continueHref !== null) {
110
+ return (
111
+ <AuthCard title={effectiveTitle}>
112
+ <div className="p-6 pt-0 flex flex-col gap-4">
113
+ <p className="text-sm text-muted-foreground">{t("auth.signupComplete.activated")}</p>
114
+ <Link href={continueHref} variant="button">
115
+ {t("auth.signupComplete.continue")}
116
+ </Link>
117
+ </div>
118
+ </AuthCard>
119
+ );
120
+ }
121
+
107
122
  return (
108
123
  <AuthCard title={effectiveTitle}>
109
124
  <div className="p-6 pt-0 flex flex-col gap-4">
@@ -0,0 +1,64 @@
1
+ // #1163: config:query:readiness read config rows via asRawClient(db).unsafe()
2
+ // directly, bypassing the #1358 closed-connection retry that only covered
3
+ // bun-db/query.ts's own selectMany/countWhere. Routed through the exported
4
+ // unsafeReadRetrying helper instead — this test mirrors
5
+ // bun-db/__tests__/select-many-retry.test.ts's fake-client pattern to prove
6
+ // the retry now actually fires for this call site.
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { selectConfigRowsForKeys, selectConfigRowsForScope } from "../resolver";
10
+
11
+ function closedConnectionError(): Error {
12
+ return Object.assign(new Error("The connection was closed."), { name: "AbortError" });
13
+ }
14
+
15
+ type FakeClient = {
16
+ unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
17
+ begin: () => never;
18
+ calls: number;
19
+ };
20
+
21
+ function fakeClient(failures: Error[]): FakeClient {
22
+ const remaining = [...failures];
23
+ const client: FakeClient = {
24
+ calls: 0,
25
+ unsafe: async () => {
26
+ client.calls++;
27
+ const err = remaining.shift();
28
+ if (err) throw err;
29
+ return [{ id: "r1", key: "k", value: "v", tenantId: "t1", userId: null }];
30
+ },
31
+ // Top-level pool client — matches what dispatch-query.ts hands buildHandlerContext
32
+ // for a standalone query.execute() call (no tx passed, resolveDbSource falls back
33
+ // to the pool connection, which has .begin()).
34
+ begin: () => {
35
+ throw new Error("not used in test");
36
+ },
37
+ };
38
+ return client;
39
+ }
40
+
41
+ describe("config db/queries/resolver — closed-connection retry (#1163)", () => {
42
+ test("selectConfigRowsForScope retries once and returns rows", async () => {
43
+ const db = fakeClient([closedConnectionError()]);
44
+ const rows = await selectConfigRowsForScope(db as never, "system", "t1", "u1");
45
+ expect(rows).toHaveLength(1);
46
+ expect(rows[0]?.key).toBe("k");
47
+ expect(db.calls).toBe(2);
48
+ });
49
+
50
+ test("selectConfigRowsForKeys retries once and returns rows", async () => {
51
+ const db = fakeClient([closedConnectionError()]);
52
+ const rows = await selectConfigRowsForKeys(db as never, ["k"], "system", "t1", "u1");
53
+ expect(rows).toHaveLength(1);
54
+ expect(db.calls).toBe(2);
55
+ });
56
+
57
+ test("gives up after the single retry when the connection stays closed", async () => {
58
+ const db = fakeClient([closedConnectionError(), closedConnectionError()]);
59
+ await expect(selectConfigRowsForScope(db as never, "system", "t1", "u1")).rejects.toThrow(
60
+ "connection was closed",
61
+ );
62
+ expect(db.calls).toBe(2);
63
+ });
64
+ });
@@ -1,4 +1,4 @@
1
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
 
@@ -16,7 +16,8 @@ export async function selectConfigRowsForScope(
16
16
  tenantId: TenantId,
17
17
  userId: string,
18
18
  ): Promise<readonly ConfigRow[]> {
19
- return asRawClient(db).unsafe<ConfigRow>(
19
+ return unsafeReadRetrying<ConfigRow>(
20
+ db,
20
21
  `SELECT id, key, value, tenant_id AS "tenantId", user_id AS "userId"
21
22
  FROM read_config_values
22
23
  WHERE (tenant_id = $1 AND user_id IS NULL)
@@ -33,7 +34,8 @@ export async function selectConfigRowsForKeys(
33
34
  tenantId: TenantId,
34
35
  userId: string,
35
36
  ): Promise<readonly ConfigRow[]> {
36
- return asRawClient(db).unsafe<ConfigRow>(
37
+ return unsafeReadRetrying<ConfigRow>(
38
+ db,
37
39
  `SELECT id, key, value, tenant_id AS "tenantId", user_id AS "userId"
38
40
  FROM read_config_values
39
41
  WHERE key = ANY($1)
@@ -10,6 +10,7 @@ import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-
10
10
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
11
11
  import { createSystemUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
12
12
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
13
+ import { createDistributedLock } from "@cosmicdrift/kumiko-framework/pipeline";
13
14
  import {
14
15
  createTestUser,
15
16
  setupTestStack,
@@ -429,3 +430,143 @@ describe("watch-supervisor — watch restart + ingest resilience", () => {
429
430
  }
430
431
  });
431
432
  });
433
+
434
+ describe("watch-supervisor — multi-worker watch coordination (#1719)", () => {
435
+ // Counts only watch-path ingests (payload.providerCursor === "watch") —
436
+ // the reconciliation poll is deliberately N-fold (every worker polls
437
+ // every account) and would otherwise inflate this count regardless of
438
+ // which worker actually holds the watch lease.
439
+ function makeWatchIngestCounter() {
440
+ let count = 0;
441
+ const dispatchWrite = ({
442
+ handlerQn,
443
+ payload,
444
+ tenantId,
445
+ }: {
446
+ handlerQn: string;
447
+ payload: unknown;
448
+ tenantId: string;
449
+ }) => {
450
+ if (handlerQn === InboundMailFoundationHandlers.ingestMessage) {
451
+ const cursor = (payload as { providerCursor?: string }).providerCursor;
452
+ if (cursor === "watch") count += 1;
453
+ }
454
+ return stack.dispatcher.write(
455
+ handlerQn,
456
+ payload,
457
+ createSystemUser(tenantId as TenantId, [ROLES.SystemAdmin]),
458
+ );
459
+ };
460
+ return { dispatchWrite, watchIngestCount: () => count };
461
+ }
462
+
463
+ test("only one supervisor holds the watch lease; the peer takes over after stop()", async () => {
464
+ const admin = adminFor(4200);
465
+ const accountId = await connectSharedAccount(admin);
466
+ const lock = createDistributedLock(stack.redis.redis, "test-watch-lease:");
467
+ const a = makeWatchIngestCounter();
468
+ const b = makeWatchIngestCounter();
469
+ const supervisorA = createInboundMailSupervisor({
470
+ providerCtx: { registry: stack.registry },
471
+ db,
472
+ dispatchWrite: a.dispatchWrite,
473
+ lock,
474
+ watchLeaseTtlSeconds: 5,
475
+ pollIntervalMs: 60_000,
476
+ });
477
+ const supervisorB = createInboundMailSupervisor({
478
+ providerCtx: { registry: stack.registry },
479
+ db,
480
+ dispatchWrite: b.dispatchWrite,
481
+ lock,
482
+ watchLeaseTtlSeconds: 5,
483
+ pollIntervalMs: 60_000,
484
+ });
485
+
486
+ try {
487
+ await supervisorA.start();
488
+ await waitFor(() => {
489
+ expect(isWatching(accountId)).toBe(true);
490
+ });
491
+
492
+ // B's own start() polls too (poll stays N-fold by design) but must
493
+ // not claim the watch lease — A already holds it.
494
+ await supervisorB.start();
495
+
496
+ await seedInboundMessage(accountId, rawMsg({ providerMessageId: "mw-1" }));
497
+ await waitFor(() => {
498
+ expect(a.watchIngestCount()).toBe(1);
499
+ });
500
+ expect(b.watchIngestCount()).toBe(0);
501
+
502
+ await supervisorA.stop();
503
+ expect(isWatching(accountId)).toBe(false);
504
+
505
+ await supervisorB.pollOnce();
506
+ await waitFor(() => {
507
+ expect(isWatching(accountId)).toBe(true);
508
+ });
509
+
510
+ await seedInboundMessage(accountId, rawMsg({ providerMessageId: "mw-2" }));
511
+ await waitFor(() => {
512
+ expect(b.watchIngestCount()).toBe(1);
513
+ });
514
+ expect(a.watchIngestCount()).toBe(1);
515
+ } finally {
516
+ await supervisorA.stop();
517
+ await supervisorB.stop();
518
+ }
519
+ });
520
+
521
+ test("the holder renews its lease past the TTL — the peer still can't claim it", async () => {
522
+ const admin = adminFor(4201);
523
+ const accountId = await connectSharedAccount(admin);
524
+ const lock = createDistributedLock(stack.redis.redis, "test-watch-lease-renew:");
525
+ const a = makeWatchIngestCounter();
526
+ const b = makeWatchIngestCounter();
527
+ const supervisorA = createInboundMailSupervisor({
528
+ providerCtx: { registry: stack.registry },
529
+ db,
530
+ dispatchWrite: a.dispatchWrite,
531
+ lock,
532
+ watchLeaseTtlSeconds: 1,
533
+ pollIntervalMs: 60_000,
534
+ });
535
+ const supervisorB = createInboundMailSupervisor({
536
+ providerCtx: { registry: stack.registry },
537
+ db,
538
+ dispatchWrite: b.dispatchWrite,
539
+ lock,
540
+ watchLeaseTtlSeconds: 1,
541
+ pollIntervalMs: 60_000,
542
+ });
543
+
544
+ try {
545
+ await supervisorA.start();
546
+ await waitFor(() => {
547
+ expect(isWatching(accountId)).toBe(true);
548
+ });
549
+
550
+ // 2.5x the 1s TTL: long enough that a dead heartbeat guarantees the
551
+ // key expired (1.5x left this indistinguishable from Redis's
552
+ // second-granularity EX not having rolled over yet).
553
+ await new Promise((r) => setTimeout(r, 2500));
554
+
555
+ // Direct probe of the invariant: the lease itself must still be held,
556
+ // independent of B's behavior below (which only infers it indirectly).
557
+ expect(await lock.acquire(accountId, { ttlSeconds: 1 })).toBeNull();
558
+
559
+ await supervisorB.start();
560
+ expect(isWatching(accountId)).toBe(true);
561
+
562
+ await seedInboundMessage(accountId, rawMsg({ providerMessageId: "mw-renew-1" }));
563
+ await waitFor(() => {
564
+ expect(a.watchIngestCount()).toBe(1);
565
+ });
566
+ expect(b.watchIngestCount()).toBe(0);
567
+ } finally {
568
+ await supervisorA.stop();
569
+ await supervisorB.stop();
570
+ }
571
+ });
572
+ });
@@ -5,27 +5,44 @@
5
5
  // Dedup im ingest-Handler macht Watch/Poll-Überschneidung idempotent —
6
6
  // der Poll ist Korrektheits-Anker, watch nur Latenz-Optimierung.
7
7
  //
8
- // **Plan-Abweichung (dokumentiert):** Der Plan sah den Poll als
9
- // `r.job`-Cron vor. JobContext hat aber KEINEN Dispatcher (verifiziert:
10
- // run-export-jobs.ts "Worker-AppContext hat kein queryAs/write") — der
11
- // ingest MUSS durch den Standard-Write-Handler (ES-Executor, PII,
12
- // Idempotency). Deshalb ist der Supervisor eine app-verdrahtete
13
- // Komponente mit `dispatchSystemWrite` in den Deps, exakt wie
14
- // billing-foundation's webhook-handler. Der App-Owner startet ihn in
15
- // bin/server.ts:
8
+ // **Plan deviation (documented):** the plan had the poll as an `r.job`
9
+ // cron trigger. At the time this was written, JobContext had no
10
+ // dispatcher (verified against run-export-jobs.ts). It now does
11
+ // (`ctx.write`/`ctx.queryAs`, job-runner.ts) but converting the poll to
12
+ // a cron job is a separate migration (job concurrency instead of this
13
+ // dispatcher contract), not part of #1719, and stays untouched here. The
14
+ // app owner still wires the supervisor in bin/server.ts:
16
15
  //
17
16
  // const supervisor = createInboundMailSupervisor({
18
17
  // providerCtx: { registry: deps.registry, secrets },
19
18
  // db,
20
19
  // dispatchWrite: ({ handlerQn, payload, tenantId }) =>
21
20
  // deps.dispatchSystemWrite({ handlerQn, payload, tenantId: tenantId as TenantId }),
21
+ // // Multi-worker deployments: share one DistributedLock (same Redis,
22
+ // // same key prefix) across every worker process so only one of them
23
+ // // holds the IMAP IDLE connection per account (#1719). Omit `lock`
24
+ // // for a single-process deployment — every active account gets
25
+ // // watched locally, same as before.
26
+ // lock: createDistributedLock(redis, `${RedisKeys.lock}inbound-mail:watch:`),
22
27
  // });
23
28
  // await supervisor.start();
24
29
  // // shutdown-hook: await supervisor.stop();
25
30
  //
26
- // **Betriebsrisiko IDLE (Plan §7.3):** langlebige Sockets im Prozess.
27
- // Mitigation hier: Backoff-Restart via onError, sauberes stop() aller
28
- // Watcher beim Shutdown, Poll fängt jede Lücke.
31
+ // **IDLE operational risk (plan §7.3):** long-lived sockets in-process.
32
+ // Mitigated here via backoff-restart on onError, a clean stop() of every
33
+ // watcher on shutdown, and the poll covering every gap.
34
+ //
35
+ // **Multi-worker coordination (#1719):** the reconciliation poll stays
36
+ // deliberately N-fold — every worker polls every active account, which
37
+ // is idempotent and cheap. Only `plugin.watch()` holds a long-lived
38
+ // connection, and only one worker may hold it per account. With
39
+ // `deps.lock`, `ensureWatcher` claims a TTL lease (`lock.acquire`) for
40
+ // the account before connecting; a worker that doesn't get it leaves the
41
+ // account to its current holder — the poll still covers it. The holder
42
+ // renews the claim via heartbeat (`lock.renew`, every ttl/3) for as long
43
+ // as the watcher state lives, including across reconnect backoff, not
44
+ // only while the connection is open. Losing the claim (Redis outage, TTL
45
+ // exceeded) tears down the local watcher instead of racing a new holder.
29
46
 
30
47
  import { fetchOne, insertOne, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
31
48
  import {
@@ -33,6 +50,7 @@ import {
33
50
  decryptPiiFieldValues,
34
51
  } from "@cosmicdrift/kumiko-framework/crypto";
35
52
  import type { DbConnection, EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
53
+ import type { DistributedLock } from "@cosmicdrift/kumiko-framework/pipeline";
36
54
  import { Temporal } from "temporal-polyfill";
37
55
  import { InboundMailAccountStatuses, InboundMailFoundationHandlers } from "./constants";
38
56
  import { MAIL_ACCOUNT_PII_FIELDS, syncCursorTable } from "./entities";
@@ -54,6 +72,7 @@ const DEFAULT_BACKFILL_WINDOW_DAYS = 30;
54
72
  const DEFAULT_MAX_MESSAGES_PER_POLL = 200;
55
73
  const DEFAULT_WATCH_BACKOFF_INITIAL_MS = 5_000;
56
74
  const DEFAULT_WATCH_BACKOFF_MAX_MS = 5 * 60 * 1000;
75
+ const DEFAULT_WATCH_LEASE_TTL_SECONDS = 90;
57
76
  /** V1: ein Cursor pro Account (eine Mailbox-Inbox). Multi-Folder später
58
77
  * über weitere scopes ohne Schema-Änderung. */
59
78
  const CURSOR_SCOPE = "default";
@@ -85,6 +104,15 @@ export type InboundMailSupervisorDeps = {
85
104
  readonly maxMessagesPerPoll?: number;
86
105
  readonly watchBackoffInitialMs?: number;
87
106
  readonly watchBackoffMaxMs?: number;
107
+ /** Coordinates the IMAP watch (not the poll) across worker processes:
108
+ * before `plugin.watch()`, ensureWatcher claims a TTL lease for the
109
+ * account via `lock.acquire` and renews it via `lock.renew` while the
110
+ * watcher lives. Omit for single-process deployments — every active
111
+ * account is watched locally, same as before #1719. */
112
+ readonly lock?: DistributedLock;
113
+ /** TTL (seconds) for the per-account watch lease. Default 90s, renewed
114
+ * every ttl/3. Only used when `lock` is set. */
115
+ readonly watchLeaseTtlSeconds?: number;
88
116
  readonly log?: (line: string) => void;
89
117
  };
90
118
 
@@ -95,8 +123,14 @@ type WatcherState = {
95
123
  /** Bump beim stop() — verhindert dass ein nachzügelnder Restart einen
96
124
  * bereits gestoppten Watcher wiederbelebt. */
97
125
  generation: number;
126
+ /** Token from `deps.lock.acquire` while this worker owns the account's
127
+ * watch lease; null when unclaimed (no `deps.lock`, or claim lost). */
128
+ lockToken: string | null;
129
+ renewTimer: ReturnType<typeof setTimeout> | null;
98
130
  };
99
131
 
132
+ type WatchLeaseResult = "acquired" | "no-token" | "stale";
133
+
100
134
  export type InboundMailSupervisor = {
101
135
  readonly start: () => Promise<void>;
102
136
  /** Ein Reconciliation-Durchlauf über alle aktiven Accounts — auch
@@ -113,6 +147,11 @@ export function createInboundMailSupervisor(
113
147
  const maxMessagesPerPoll = deps.maxMessagesPerPoll ?? DEFAULT_MAX_MESSAGES_PER_POLL;
114
148
  const backoffInitialMs = deps.watchBackoffInitialMs ?? DEFAULT_WATCH_BACKOFF_INITIAL_MS;
115
149
  const backoffMaxMs = deps.watchBackoffMaxMs ?? DEFAULT_WATCH_BACKOFF_MAX_MS;
150
+ const leaseTtlSeconds = deps.watchLeaseTtlSeconds ?? DEFAULT_WATCH_LEASE_TTL_SECONDS;
151
+ // ttl/3 keeps at least two renewal attempts inside the TTL window before
152
+ // it lapses. The 50ms floor only guards against a pathologically small
153
+ // configured TTL — real deployments (default 90s) never hit it.
154
+ const renewIntervalMs = Math.max(50, Math.floor((leaseTtlSeconds * 1000) / 3));
116
155
  const log = deps.log ?? (() => {});
117
156
 
118
157
  let running = false;
@@ -343,6 +382,72 @@ export function createInboundMailSupervisor(
343
382
  // ---------------------------------------------------------------
344
383
  // Watch-Lifecycle mit Backoff-Restart.
345
384
  // ---------------------------------------------------------------
385
+ // Heartbeat for a held watch lease. Independent of the connection's own
386
+ // lifecycle — it keeps renewing across reconnect backoff, not just while
387
+ // `plugin.watch()` is actually connected, so a flaky IMAP link doesn't
388
+ // make this worker lose the account to a peer mid-backoff.
389
+ function scheduleRenew(
390
+ account: MailAccountRecord,
391
+ state: WatcherState,
392
+ generation: number,
393
+ ): void {
394
+ state.renewTimer = setTimeout(() => {
395
+ void (async () => {
396
+ state.renewTimer = null;
397
+ // skip: supervisor stopped, generation superseded, or lease already lost — stale timer fire, nothing to renew.
398
+ if (!running || state.generation !== generation || !state.lockToken || !deps.lock) return;
399
+ let renewed: boolean;
400
+ try {
401
+ renewed = await deps.lock.renew(account.id, state.lockToken, leaseTtlSeconds);
402
+ } catch (err) {
403
+ log(
404
+ `inbound-mail: watch lease renew for account ${account.id} failed: ${err instanceof Error ? err.message : String(err)}`,
405
+ );
406
+ // Treat a renew error as transient (Redis blip) — keep the local
407
+ // watcher running and retry next tick within the TTL grace window.
408
+ if (running && state.generation === generation && state.lockToken) {
409
+ scheduleRenew(account, state, generation);
410
+ }
411
+ // skip: renew already rescheduled above (or conditions no longer hold) — nothing left to do this tick.
412
+ return;
413
+ }
414
+ if (!renewed) {
415
+ log(
416
+ `inbound-mail: watch lease for account ${account.id} lost — tearing down local watcher`,
417
+ );
418
+ state.lockToken = null;
419
+ await stopWatcher(account.id);
420
+ // skip: watcher already torn down by stopWatcher() above — nothing left to do.
421
+ return;
422
+ }
423
+ if (running && state.generation === generation && state.lockToken) {
424
+ scheduleRenew(account, state, generation);
425
+ }
426
+ })();
427
+ }, renewIntervalMs);
428
+ }
429
+
430
+ async function acquireWatchLease(
431
+ account: MailAccountRecord,
432
+ state: WatcherState,
433
+ ): Promise<WatchLeaseResult> {
434
+ if (!deps.lock || state.lockToken) return "acquired";
435
+ const token = await deps.lock.acquire(account.id, { ttlSeconds: leaseTtlSeconds });
436
+ if (!token) return "no-token";
437
+ // The acquire() await is a yield point: a concurrent stopWatcher()
438
+ // (e.g. from a lost-lease renewal on a different generation) may have
439
+ // already retired this exact state and removed it from the map. If so,
440
+ // committing the fresh token onto it would leak the lease — nothing
441
+ // would ever release it, blocking failover for the full TTL.
442
+ if (watchers.get(account.id) !== state) {
443
+ await deps.lock.release(account.id, token);
444
+ return "stale";
445
+ }
446
+ state.lockToken = token;
447
+ scheduleRenew(account, state, state.generation);
448
+ return "acquired";
449
+ }
450
+
346
451
  async function ensureWatcher(
347
452
  account: MailAccountRecord,
348
453
  plugin: InboundMailProviderPlugin,
@@ -358,8 +463,26 @@ export function createInboundMailSupervisor(
358
463
  backoffMs: backoffInitialMs,
359
464
  restartTimer: null,
360
465
  generation: 0,
466
+ lockToken: null,
467
+ renewTimer: null,
361
468
  };
362
469
  watchers.set(account.id, state);
470
+
471
+ const leaseResult = await acquireWatchLease(account, state);
472
+ if (leaseResult === "no-token") {
473
+ // Another worker already holds this account's watch lease — the poll
474
+ // still covers it, this worker just doesn't open a second IDLE
475
+ // connection. Retried on the next tick.
476
+ if (!existing) watchers.delete(account.id);
477
+ // skip: no lease token acquired above — nothing more to set up here.
478
+ return;
479
+ }
480
+ if (leaseResult === "stale") {
481
+ // skip: state was retired by a concurrent stopWatcher while acquire()
482
+ // awaited — committing here would leak the lease.
483
+ return;
484
+ }
485
+
363
486
  const generation = state.generation;
364
487
 
365
488
  const scheduleRestart = (err: unknown) => {
@@ -434,8 +557,17 @@ export function createInboundMailSupervisor(
434
557
  clearTimeout(state.restartTimer);
435
558
  state.restartTimer = null;
436
559
  }
560
+ if (state.renewTimer) {
561
+ clearTimeout(state.renewTimer);
562
+ state.renewTimer = null;
563
+ }
437
564
  const stop = state.stop;
438
565
  state.stop = null;
566
+ // Read the token before clearing it: only OUR claim gets released — a
567
+ // caller that already cleared lockToken (lease-lost path in
568
+ // scheduleRenew) means someone else may own it by now.
569
+ const lockToken = state.lockToken;
570
+ state.lockToken = null;
439
571
  watchers.delete(accountId);
440
572
  if (stop) {
441
573
  try {
@@ -446,6 +578,15 @@ export function createInboundMailSupervisor(
446
578
  );
447
579
  }
448
580
  }
581
+ if (deps.lock && lockToken) {
582
+ try {
583
+ await deps.lock.release(accountId, lockToken);
584
+ } catch (err) {
585
+ log(
586
+ `inbound-mail: watch lease release for account ${accountId} failed: ${err instanceof Error ? err.message : String(err)}`,
587
+ );
588
+ }
589
+ }
449
590
  }
450
591
 
451
592
  // ---------------------------------------------------------------