@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 +8 -8
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +31 -2
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +175 -2
- package/src/auth-email-password/feature.ts +8 -4
- package/src/auth-email-password/handlers/invite-create.write.ts +14 -4
- package/src/auth-email-password/handlers/signup-request.write.ts +48 -34
- package/src/auth-email-password/handlers/token-request-handler.ts +20 -4
- package/src/auth-email-password/i18n.ts +2 -0
- package/src/auth-email-password/magic-link-mail.ts +11 -2
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +15 -6
- package/src/auth-email-password/web/signup-complete-screen.tsx +29 -14
- package/src/config/db/queries/__tests__/resolver.test.ts +64 -0
- package/src/config/db/queries/resolver.ts +5 -3
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +141 -0
- package/src/inbound-mail-foundation/watch-supervisor.ts +152 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "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.
|
|
130
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
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(
|
|
150
|
-
|
|
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(
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
+
locale,
|
|
172
182
|
},
|
|
173
183
|
);
|
|
174
184
|
|
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
// Magic-
|
|
1
|
+
// Magic-link signup, step 1 (request).
|
|
2
2
|
//
|
|
3
|
-
// User
|
|
4
|
-
//
|
|
5
|
-
// via delivery (ctx.notify) —
|
|
6
|
-
// userId
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// User enters an email → we 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 subject — normally
|
|
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):
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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
|
|
57
|
-
* "
|
|
56
|
+
/** TTL for the activation token. Default 24h — long 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
|
-
|
|
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
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// lowercased haben.
|
|
109
|
+
// Email normalization lives in the store (signup-token-store). The
|
|
110
|
+
// handler passes the raw email through — one 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
|
|
115
|
-
// xorshift128+
|
|
116
|
-
//
|
|
117
|
-
// signup-requests
|
|
118
|
-
//
|
|
119
|
-
//
|
|
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 outputs — an 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
|
|
128
|
-
//
|
|
129
|
-
//
|
|
131
|
+
// normalizeEmail from the store — one 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
|
-
|
|
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).
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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 →
|
|
61
|
-
const
|
|
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 =
|
|
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(
|
|
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
|
-
//
|
|
5
|
-
// Submit
|
|
6
|
-
//
|
|
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
|
|
10
|
-
//
|
|
11
|
-
// server-
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
9
|
-
//
|
|
10
|
-
// run-export-jobs.ts
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
-
// **
|
|
27
|
-
//
|
|
28
|
-
//
|
|
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
|
// ---------------------------------------------------------------
|