@cosmicdrift/kumiko-bundled-features 0.157.0 → 0.157.2

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.157.0",
3
+ "version": "0.157.2",
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>",
@@ -116,11 +116,11 @@
116
116
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
117
117
  },
118
118
  "dependencies": {
119
- "@cosmicdrift/kumiko-dispatcher-live": "0.157.0",
120
- "@cosmicdrift/kumiko-framework": "0.157.0",
121
- "@cosmicdrift/kumiko-headless": "0.157.0",
122
- "@cosmicdrift/kumiko-renderer": "0.157.0",
123
- "@cosmicdrift/kumiko-renderer-web": "0.157.0",
119
+ "@cosmicdrift/kumiko-dispatcher-live": "0.157.2",
120
+ "@cosmicdrift/kumiko-framework": "0.157.2",
121
+ "@cosmicdrift/kumiko-headless": "0.157.2",
122
+ "@cosmicdrift/kumiko-renderer": "0.157.2",
123
+ "@cosmicdrift/kumiko-renderer-web": "0.157.2",
124
124
  "@mollie/api-client": "^4.5.0",
125
125
  "imapflow": "^1.3.3",
126
126
  "mailparser": "^3.9.8",
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ invalidChallengeToken,
4
+ invalidRecoveryCode,
5
+ invalidSetupToken,
6
+ invalidTotpCode,
7
+ mfaAlreadyEnabled,
8
+ mfaNotEnabled,
9
+ tooManyAttempts,
10
+ } from "../errors";
11
+ import { defaultTranslations } from "../web/i18n";
12
+
13
+ const failures = {
14
+ mfaAlreadyEnabled: mfaAlreadyEnabled(),
15
+ mfaNotEnabled: mfaNotEnabled(),
16
+ invalidSetupToken: invalidSetupToken(),
17
+ invalidTotpCode: invalidTotpCode(),
18
+ invalidRecoveryCode: invalidRecoveryCode(),
19
+ invalidChallengeToken: invalidChallengeToken(),
20
+ tooManyAttempts: tooManyAttempts(30),
21
+ };
22
+
23
+ describe("auth-mfa errors.ts i18nKey / client-bundle parity", () => {
24
+ for (const [name, failure] of Object.entries(failures)) {
25
+ test(`${name}'s i18nKey is registered in en/de translations`, () => {
26
+ const key = failure.error.i18nKey;
27
+ expect(defaultTranslations["en"]?.[key]).toBeDefined();
28
+ expect(defaultTranslations["de"]?.[key]).toBeDefined();
29
+ });
30
+ }
31
+ });
@@ -19,10 +19,9 @@ export const AuthMfaQueries = {
19
19
  status: "auth-mfa:query:user-mfa:status",
20
20
  } as const;
21
21
 
22
- // Write-failure codes shared between errors.ts (server, mints them into
23
- // UnprocessableError) and web/mfa-error-keys.ts (client, maps them to i18n
24
- // keys) one source so a renamed code can't drift out of sync between the
25
- // two switches.
22
+ // Write-failure codes minted server-side into UnprocessableError (errors.ts).
23
+ // The client reads the i18n key straight off the write result
24
+ // (res.error.i18nKey) instead of re-deriving it from these codes.
26
25
  export const AuthMfaErrorCodes = {
27
26
  mfaAlreadyEnabled: "mfa_already_enabled",
28
27
  mfaNotEnabled: "mfa_not_enabled",
@@ -31,10 +30,6 @@ export const AuthMfaErrorCodes = {
31
30
  invalidRecoveryCode: "invalid_recovery_code",
32
31
  invalidChallengeToken: "invalid_challenge_token",
33
32
  tooManyAttempts: "too_many_attempts",
34
- // Client-only: minted by mfa-enable-screen.tsx on a network/QR-render
35
- // failure during enrollment, never by errors.ts server-side — kept here
36
- // anyway so mfa-error-keys.ts's key-parity test covers it automatically.
37
- setupFailed: "setup_failed",
38
33
  } as const;
39
34
 
40
35
  export const MFA_SETUP_TOKEN_TTL_MINUTES = 10;
@@ -10,7 +10,7 @@ export { AuthMfaErrorCodes as AuthMfaErrors } from "./constants";
10
10
  export function mfaAlreadyEnabled(): WriteFailure {
11
11
  return writeFailure(
12
12
  new UnprocessableError(AuthMfaErrorCodes.mfaAlreadyEnabled, {
13
- i18nKey: "authMfa.errors.mfaAlreadyEnabled",
13
+ i18nKey: "auth.mfa.errors.mfaAlreadyEnabled",
14
14
  }),
15
15
  );
16
16
  }
@@ -18,7 +18,7 @@ export function mfaAlreadyEnabled(): WriteFailure {
18
18
  export function mfaNotEnabled(): WriteFailure {
19
19
  return writeFailure(
20
20
  new UnprocessableError(AuthMfaErrorCodes.mfaNotEnabled, {
21
- i18nKey: "authMfa.errors.mfaNotEnabled",
21
+ i18nKey: "auth.mfa.errors.mfaNotEnabled",
22
22
  }),
23
23
  );
24
24
  }
@@ -26,7 +26,7 @@ export function mfaNotEnabled(): WriteFailure {
26
26
  export function invalidSetupToken(): WriteFailure {
27
27
  return writeFailure(
28
28
  new UnprocessableError(AuthMfaErrorCodes.invalidSetupToken, {
29
- i18nKey: "authMfa.errors.invalidSetupToken",
29
+ i18nKey: "auth.mfa.errors.invalidSetupToken",
30
30
  }),
31
31
  );
32
32
  }
@@ -34,7 +34,7 @@ export function invalidSetupToken(): WriteFailure {
34
34
  export function invalidTotpCode(): WriteFailure {
35
35
  return writeFailure(
36
36
  new UnprocessableError(AuthMfaErrorCodes.invalidTotpCode, {
37
- i18nKey: "authMfa.errors.invalidTotpCode",
37
+ i18nKey: "auth.mfa.errors.invalidTotpCode",
38
38
  }),
39
39
  );
40
40
  }
@@ -42,7 +42,7 @@ export function invalidTotpCode(): WriteFailure {
42
42
  export function invalidRecoveryCode(): WriteFailure {
43
43
  return writeFailure(
44
44
  new UnprocessableError(AuthMfaErrorCodes.invalidRecoveryCode, {
45
- i18nKey: "authMfa.errors.invalidRecoveryCode",
45
+ i18nKey: "auth.mfa.errors.invalidRecoveryCode",
46
46
  }),
47
47
  );
48
48
  }
@@ -50,7 +50,7 @@ export function invalidRecoveryCode(): WriteFailure {
50
50
  export function invalidChallengeToken(): WriteFailure {
51
51
  return writeFailure(
52
52
  new UnprocessableError(AuthMfaErrorCodes.invalidChallengeToken, {
53
- i18nKey: "authMfa.errors.invalidChallengeToken",
53
+ i18nKey: "auth.mfa.errors.invalidChallengeToken",
54
54
  }),
55
55
  );
56
56
  }
@@ -59,7 +59,7 @@ export function invalidChallengeToken(): WriteFailure {
59
59
  export function tooManyAttempts(retryAfterSeconds: number): WriteFailure {
60
60
  return writeFailure(
61
61
  new UnprocessableError(AuthMfaErrorCodes.tooManyAttempts, {
62
- i18nKey: "authMfa.errors.tooManyAttempts",
62
+ i18nKey: "auth.mfa.errors.tooManyAttempts",
63
63
  details: { retryAfterSeconds },
64
64
  }),
65
65
  );
@@ -15,6 +15,9 @@ export const defaultTranslations: TranslationsByLocale = {
15
15
  "auth.mfa.verify.submitting": "…",
16
16
  "auth.mfa.verify.backToLogin": "Zurück zum Login",
17
17
  "auth.mfa.errors.invalidCode": "Ungültiger Code. Bitte erneut versuchen.",
18
+ "auth.mfa.errors.invalidTotpCode": "Ungültiger Code. Bitte erneut versuchen.",
19
+ "auth.mfa.errors.invalidChallengeToken":
20
+ "Die Anmeldung ist abgelaufen. Bitte erneut einloggen.",
18
21
  "auth.mfa.errors.challengeExpired": "Die Anmeldung ist abgelaufen. Bitte erneut einloggen.",
19
22
  "auth.mfa.errors.tooManyAttempts": "Zu viele Fehlversuche. Bitte erneut einloggen.",
20
23
  "auth.mfa.errors.verifyFailed": "Bestätigung fehlgeschlagen.",
@@ -66,6 +69,8 @@ export const defaultTranslations: TranslationsByLocale = {
66
69
  "auth.mfa.verify.submitting": "…",
67
70
  "auth.mfa.verify.backToLogin": "Back to login",
68
71
  "auth.mfa.errors.invalidCode": "Invalid code. Please try again.",
72
+ "auth.mfa.errors.invalidTotpCode": "Invalid code. Please try again.",
73
+ "auth.mfa.errors.invalidChallengeToken": "Your sign-in has expired. Please sign in again.",
69
74
  "auth.mfa.errors.challengeExpired": "Your sign-in has expired. Please sign in again.",
70
75
  "auth.mfa.errors.tooManyAttempts": "Too many failed attempts. Please sign in again.",
71
76
  "auth.mfa.errors.verifyFailed": "Verification failed.",
@@ -13,7 +13,6 @@ export type { MfaDisableDialogProps } from "./mfa-disable-dialog";
13
13
  export { MfaDisableDialog } from "./mfa-disable-dialog";
14
14
  export type { MfaEnableScreenProps } from "./mfa-enable-screen";
15
15
  export { MfaEnableScreen } from "./mfa-enable-screen";
16
- export { mfaManageErrorKey } from "./mfa-error-keys";
17
16
  export type { MfaRecoveryCodesRevealProps } from "./mfa-recovery-codes-reveal";
18
17
  export { MfaRecoveryCodesReveal } from "./mfa-recovery-codes-reveal";
19
18
  export type { MfaRegenerateRecoveryDialogProps } from "./mfa-regenerate-recovery-dialog";
@@ -13,7 +13,6 @@
13
13
  import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
14
14
  import { type ReactNode, useState } from "react";
15
15
  import { AuthMfaHandlers } from "../constants";
16
- import { mfaManageErrorKey } from "./mfa-error-keys";
17
16
 
18
17
  export type MfaDisableDialogProps = {
19
18
  readonly open: boolean;
@@ -37,7 +36,7 @@ export function MfaDisableDialog({
37
36
  const res = await dispatcher.write<{ disabled: boolean }>(AuthMfaHandlers.disable, { code });
38
37
  setCode("");
39
38
  if (!res.isSuccess) {
40
- onError(mfaManageErrorKey(res.error.code));
39
+ onError(res.error.i18nKey);
41
40
  return;
42
41
  }
43
42
  onDisabled();
@@ -21,7 +21,6 @@ import { type ReactNode, useState } from "react";
21
21
  // kumiko-lint-ignore cross-feature-import client-only hook, the feature's server barrel has no web/ re-export
22
22
  import { useSession } from "../../auth-email-password/web";
23
23
  import { AuthMfaHandlers } from "../constants";
24
- import { mfaManageErrorKey } from "./mfa-error-keys";
25
24
 
26
25
  type EnableStartResponse = {
27
26
  readonly setupToken: string;
@@ -77,7 +76,7 @@ export function MfaEnableScreen({
77
76
  accountLabel: session.user?.email ?? "",
78
77
  });
79
78
  if (!res.isSuccess) {
80
- setError(res.error.code);
79
+ setError(res.error.i18nKey);
81
80
  return;
82
81
  }
83
82
  // errorCorrectionLevel "H" (~30% redundancy) — more resilient to
@@ -96,7 +95,7 @@ export function MfaEnableScreen({
96
95
  setAcknowledged(false);
97
96
  setCode("");
98
97
  } catch {
99
- setError("setup_failed");
98
+ setError("auth.mfa.errors.setupFailed");
100
99
  } finally {
101
100
  setBusy(false);
102
101
  }
@@ -112,14 +111,14 @@ export function MfaEnableScreen({
112
111
  code,
113
112
  });
114
113
  if (!res.isSuccess) {
115
- setError(res.error.code);
114
+ setError(res.error.i18nKey);
116
115
  return;
117
116
  }
118
117
  setEnabled(true);
119
118
  setSetup(null);
120
119
  onEnabled?.();
121
120
  } catch {
122
- setError("setup_failed");
121
+ setError("auth.mfa.errors.setupFailed");
123
122
  } finally {
124
123
  setBusy(false);
125
124
  }
@@ -130,7 +129,7 @@ export function MfaEnableScreen({
130
129
  <Heading>{t("auth.mfa.enable.title")}</Heading>
131
130
 
132
131
  {enabled && <Banner variant="info">{t("auth.mfa.enable.success")}</Banner>}
133
- {error !== null && <Banner variant="error">{t(mfaManageErrorKey(error))}</Banner>}
132
+ {error !== null && <Banner variant="error">{t(error)}</Banner>}
134
133
 
135
134
  {!setup && !enabled && (
136
135
  <Section
@@ -10,7 +10,6 @@
10
10
  import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
11
11
  import { type ReactNode, useState } from "react";
12
12
  import { AuthMfaHandlers } from "../constants";
13
- import { mfaManageErrorKey } from "./mfa-error-keys";
14
13
 
15
14
  export type MfaRegenerateRecoveryDialogProps = {
16
15
  readonly open: boolean;
@@ -37,7 +36,7 @@ export function MfaRegenerateRecoveryDialog({
37
36
  );
38
37
  setCode("");
39
38
  if (!res.isSuccess) {
40
- onError(mfaManageErrorKey(res.error.code));
39
+ onError(res.error.i18nKey);
41
40
  return;
42
41
  }
43
42
  onRegenerated(res.data.recoveryCodes);
@@ -0,0 +1,217 @@
1
+ // Regression guard for kumiko-framework#1188: proves a write interleaved
2
+ // with a running rotation survives — the event-store executor's optimistic
3
+ // concurrency check rejects the rotation job's stale-version update, so the
4
+ // version_conflict branch in reencrypt.job.ts's migrateRow (currently just
5
+ // "a concurrent config:set beat us; the row now holds a fresh envelope —
6
+ // already fine.") is verified, not just an unverified comment. Modeled on
7
+ // reencrypt-job-kek-rotation.integration.test.ts (kumiko-framework#1187)
8
+ // for the stack/rotation setup.
9
+ //
10
+ // The race is simulated deterministically: the job context's
11
+ // configEncryption.encrypt is wrapped so the FIRST call (which migrateRow
12
+ // makes right before its own executor.update) performs a second, unrelated
13
+ // write to the same row first. That write lands on the row version the job
14
+ // captured at batch-read time, so the job's own update — issued a moment
15
+ // later with that now-stale version — is guaranteed to hit version_conflict.
16
+
17
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
18
+ import { randomBytes } from "node:crypto";
19
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
20
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
21
+ import {
22
+ access,
23
+ createSystemConfig,
24
+ createSystemUser,
25
+ defineFeature,
26
+ SYSTEM_TENANT_ID,
27
+ } from "@cosmicdrift/kumiko-framework/engine";
28
+ import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
29
+ import {
30
+ createEnvelopeCipher,
31
+ createEnvMasterKeyProvider,
32
+ type EnvelopeCipher,
33
+ } from "@cosmicdrift/kumiko-framework/secrets";
34
+ import {
35
+ setupTestStack,
36
+ type TestStack,
37
+ unsafePushTables,
38
+ } from "@cosmicdrift/kumiko-framework/stack";
39
+ import { createMutableMasterKeyProvider } from "@cosmicdrift/kumiko-framework/testing";
40
+ import { createConfigFeature } from "../feature";
41
+ import { reencryptJob } from "../handlers/reencrypt.job";
42
+ import { createConfigResolver } from "../resolver";
43
+ import { configValueEntity, configValuesTable } from "../table";
44
+
45
+ const KEY = "kek-rot-race:config:race-pass";
46
+ const ORIGINAL_PLAINTEXT = "pre-race-value";
47
+ const CONCURRENT_PLAINTEXT = "concurrent-write-value";
48
+ const PROJECTION_NAME = "config:projection:config-value-entity";
49
+
50
+ const v1Key = randomBytes(32).toString("base64");
51
+ const v2Key = randomBytes(32).toString("base64");
52
+ const mutableProvider = createMutableMasterKeyProvider(
53
+ createEnvMasterKeyProvider({
54
+ env: {
55
+ KUMIKO_SECRETS_MASTER_KEY_V1: v1Key,
56
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
57
+ },
58
+ }),
59
+ );
60
+ const cipher = createEnvelopeCipher(mutableProvider, {});
61
+ const resolver = createConfigResolver({ cipher });
62
+
63
+ const keyDef = createSystemConfig("text", {
64
+ encrypted: true,
65
+ read: access.systemAdmin,
66
+ write: access.systemAdmin,
67
+ });
68
+
69
+ const rotFeature = defineFeature("kek-rot-race", (r) => {
70
+ r.requires("config");
71
+ r.config({ keys: { "race-pass": keyDef } });
72
+ });
73
+
74
+ const executor = createEventStoreExecutor(configValuesTable, configValueEntity, {
75
+ entityName: "config-value",
76
+ });
77
+
78
+ let stack: TestStack;
79
+
80
+ type ConfigRow = {
81
+ id: string;
82
+ key: string;
83
+ value: string | null;
84
+ tenantId: string;
85
+ version: number;
86
+ };
87
+
88
+ async function readRow(): Promise<ConfigRow> {
89
+ const rows = await selectMany<ConfigRow>(stack.db, configValuesTable, { key: KEY });
90
+ const row = rows[0];
91
+ if (!row) throw new Error("no config row");
92
+ return row;
93
+ }
94
+
95
+ async function seedV1Row(): Promise<void> {
96
+ const envelope = await cipher.encrypt(JSON.stringify(ORIGINAL_PLAINTEXT), {
97
+ tenantId: SYSTEM_TENANT_ID,
98
+ });
99
+ const systemUser = createSystemUser(SYSTEM_TENANT_ID);
100
+ const tdb = createTenantDb(stack.db, SYSTEM_TENANT_ID, "system");
101
+ const result = await executor.create(
102
+ { key: KEY, value: envelope, tenantId: SYSTEM_TENANT_ID, userId: null },
103
+ systemUser,
104
+ tdb,
105
+ );
106
+ if (!result.isSuccess) throw new Error(`seed failed: ${result.error.code}`);
107
+ }
108
+
109
+ // Writes CONCURRENT_PLAINTEXT to the row using the version the rotation job
110
+ // itself captured at batch-read time — the same version-N -> N+1 bump a
111
+ // real interleaved handler write would cause.
112
+ async function writeConcurrently(atVersion: number): Promise<void> {
113
+ const envelope = await cipher.encrypt(JSON.stringify(CONCURRENT_PLAINTEXT), {
114
+ tenantId: SYSTEM_TENANT_ID,
115
+ });
116
+ const systemUser = createSystemUser(SYSTEM_TENANT_ID);
117
+ const tdb = createTenantDb(stack.db, SYSTEM_TENANT_ID, "system");
118
+ const row = await readRow();
119
+ const result = await executor.update(
120
+ { id: row.id, version: atVersion, changes: { value: envelope } },
121
+ systemUser,
122
+ tdb,
123
+ );
124
+ if (!result.isSuccess) throw new Error(`concurrent write setup failed: ${result.error.code}`);
125
+ }
126
+
127
+ const noopLog = {
128
+ info: () => {},
129
+ warn: () => {},
130
+ error: () => {},
131
+ debug: () => {},
132
+ child: () => noopLog,
133
+ };
134
+
135
+ // Fires the concurrent write on the first encrypt() call the job makes —
136
+ // that's the point in migrateRow right after decrypt, right before its own
137
+ // executor.update, so the job's captured row.version is guaranteed stale by
138
+ // the time it issues that update.
139
+ function racyJobCtx(rowVersionAtBatchRead: number): Parameters<typeof reencryptJob>[1] {
140
+ let fired = false;
141
+ const racyCipher: EnvelopeCipher = {
142
+ decrypt: (stored, scope) => cipher.decrypt(stored, scope),
143
+ encrypt: async (plaintext, scope) => {
144
+ if (!fired) {
145
+ fired = true;
146
+ await writeConcurrently(rowVersionAtBatchRead);
147
+ }
148
+ return cipher.encrypt(plaintext, scope);
149
+ },
150
+ };
151
+ return {
152
+ db: stack.db,
153
+ registry: stack.registry,
154
+ masterKeyProvider: mutableProvider,
155
+ configEncryption: racyCipher,
156
+ log: noopLog,
157
+ } as unknown as Parameters<typeof reencryptJob>[1]; // @cast-boundary test-seam — job only reads db/registry/masterKeyProvider/configEncryption/log
158
+ }
159
+
160
+ beforeAll(async () => {
161
+ stack = await setupTestStack({
162
+ features: [createConfigFeature(), rotFeature],
163
+ masterKeyProvider: mutableProvider,
164
+ extraContext: { configResolver: resolver, configEncryption: cipher },
165
+ });
166
+ await unsafePushTables(stack.db, { configValuesTable });
167
+ await seedV1Row();
168
+ });
169
+
170
+ afterAll(async () => {
171
+ await stack.cleanup();
172
+ });
173
+
174
+ describe("config KEK-rotation job — concurrent write race (kumiko-framework#1188)", () => {
175
+ test("a write interleaved with a running rotation wins — rotation's stale update is rejected, not silently applied", async () => {
176
+ const seeded = await readRow();
177
+ expect(JSON.parse(seeded.value as string).kekVersion).toBe(1);
178
+
179
+ // "ops flips CURRENT=2 mid-flight" — same rotation trigger as #1187's test.
180
+ mutableProvider.replace(
181
+ createEnvMasterKeyProvider({
182
+ env: {
183
+ KUMIKO_SECRETS_MASTER_KEY_V1: v1Key,
184
+ KUMIKO_SECRETS_MASTER_KEY_V2: v2Key,
185
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2",
186
+ },
187
+ }),
188
+ );
189
+
190
+ await reencryptJob({}, racyJobCtx(seeded.version));
191
+
192
+ // The concurrent write already lands under the current key — its
193
+ // envelope must survive untouched; the rotation job's own update lost
194
+ // the version_conflict race and must not have overwritten it.
195
+ const afterJobRow = await readRow();
196
+ expect(JSON.parse(afterJobRow.value as string).kekVersion).toBe(2);
197
+ expect(await resolver.get(KEY, keyDef, SYSTEM_TENANT_ID, "u1", stack.db)).toBe(
198
+ CONCURRENT_PLAINTEXT,
199
+ );
200
+
201
+ // Rebuild guard: a from-scratch replay must land on the concurrent
202
+ // write's event, not resurrect the rotation job's rejected update (it
203
+ // never became an event) nor the original pre-race value.
204
+ expect(stack.registry.getAllProjections().has(PROJECTION_NAME)).toBe(true);
205
+ const rebuildResult = await rebuildProjection(PROJECTION_NAME, {
206
+ db: stack.db,
207
+ registry: stack.registry,
208
+ });
209
+ expect(rebuildResult.eventsProcessed).toBeGreaterThan(0);
210
+
211
+ const afterRebuildRow = await readRow();
212
+ expect(JSON.parse(afterRebuildRow.value as string).kekVersion).toBe(2);
213
+ expect(await resolver.get(KEY, keyDef, SYSTEM_TENANT_ID, "u1", stack.db)).toBe(
214
+ CONCURRENT_PLAINTEXT,
215
+ );
216
+ });
217
+ });
@@ -149,6 +149,22 @@ describe("FolderManager filing mode", () => {
149
149
  );
150
150
  });
151
151
 
152
+ test("deleting a folder in filing mode refetches AND tells the host to reassign", async () => {
153
+ folderRows = [{ id: "f1", name: "A", parentId: null, version: 1 }];
154
+ const onReassigned = mock(() => {});
155
+ render(
156
+ <Wrapper>
157
+ <FolderManager filing={filingWith(onReassigned)} />
158
+ </Wrapper>,
159
+ );
160
+ fireEvent.click(screen.getByTestId("folder-delete-f1"));
161
+ fireEvent.click(await screen.findByTestId("folder-manager-delete-dialog-confirm"));
162
+ await waitFor(() =>
163
+ expect(dispatchSpy).toHaveBeenCalledWith(FoldersHandlers.deleteFolder, { id: "f1" }),
164
+ );
165
+ await waitFor(() => expect(onReassigned).toHaveBeenCalled());
166
+ });
167
+
152
168
  test("the in-tree new-folder row opens a draft; submitting (Enter) creates a root folder", async () => {
153
169
  folderRows = [];
154
170
  render(
@@ -181,6 +181,7 @@ export function FolderManager({
181
181
  if (await write()) {
182
182
  setPending(null);
183
183
  await catalog.refetch();
184
+ await filing?.onReassigned();
184
185
  }
185
186
  } finally {
186
187
  setBusy(false);
@@ -22,13 +22,22 @@ export const invitationsQuery = defineQueryHandler({
22
22
  tenantId: query.user.tenantId,
23
23
  status: INVITATION_STATUS.pending,
24
24
  });
25
- return Promise.all(
26
- (rows ?? []).map(async (row) => {
27
- const email = row["email"];
28
- return typeof email === "string"
29
- ? { ...row, email: await decryptStoredPii(email, "tenant:invitations") }
30
- : row;
31
- }),
32
- );
25
+ // Sequential, not Promise.all: each decrypt hits the KMS adapter's own
26
+ // small dedicated pool (PgKmsAdapter default max: 4) firing 2 calls
27
+ // per row concurrently for every row exhausts it once invitation counts
28
+ // exceed a handful, surfacing as "the connection was closed".
29
+ const out: Record<string, unknown>[] = [];
30
+ for (const row of rows ?? []) {
31
+ const email = row["email"];
32
+ const invitedBy = row["invitedBy"];
33
+ const decryptedEmail =
34
+ typeof email === "string" ? await decryptStoredPii(email, "tenant:invitations") : email;
35
+ const decryptedInvitedBy =
36
+ typeof invitedBy === "string"
37
+ ? await decryptStoredPii(invitedBy, "tenant:invitations")
38
+ : invitedBy;
39
+ out.push({ ...row, email: decryptedEmail, invitedBy: decryptedInvitedBy });
40
+ }
41
+ return out;
33
42
  },
34
43
  });
@@ -2,6 +2,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
4
4
  import { z } from "zod";
5
+ import { decryptStoredPii } from "../../shared";
5
6
  import { userTable } from "../../user";
6
7
  import { tenantMembershipsTable } from "../membership-table";
7
8
 
@@ -21,14 +22,33 @@ export const membersQuery = defineQueryHandler({
21
22
  userIds.length > 0 ? await selectMany<UserRow>(ctx.db, userTable, { id: userIds }) : [];
22
23
  const userById = new Map(users.map((u) => [String(u.id), u]));
23
24
 
25
+ // Sequential, not Promise.all: each decrypt hits the KMS adapter's own
26
+ // small dedicated pool (PgKmsAdapter default max: 4) — firing 2 calls
27
+ // per row concurrently for every row exhausts it once membership counts
28
+ // exceed a handful, surfacing as "the connection was closed".
29
+ const decryptedByUserId = new Map<
30
+ string,
31
+ { email: string | null; displayName: string | null }
32
+ >();
33
+ for (const user of users) {
34
+ const email =
35
+ typeof user.email === "string"
36
+ ? await decryptStoredPii(user.email, "tenant:members")
37
+ : null;
38
+ const displayName =
39
+ typeof user.displayName === "string"
40
+ ? await decryptStoredPii(user.displayName, "tenant:members")
41
+ : null;
42
+ decryptedByUserId.set(String(user.id), { email, displayName });
43
+ }
44
+
24
45
  return rows.map((row) => {
25
46
  const user = userById.get(String(row["userId"]));
26
- const email = typeof user?.email === "string" ? user.email : null;
27
- const displayName = typeof user?.displayName === "string" ? user.displayName : null;
47
+ const decrypted = user ? decryptedByUserId.get(String(user.id)) : undefined;
28
48
  return {
29
49
  ...row,
30
- email,
31
- displayName,
50
+ email: decrypted?.email ?? null,
51
+ displayName: decrypted?.displayName ?? null,
32
52
  roles: parseRoles(row["roles"]),
33
53
  };
34
54
  });
@@ -1,20 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { AuthMfaErrorCodes } from "../../constants";
3
- import { defaultTranslations } from "../i18n";
4
- import { mfaManageErrorKey } from "../mfa-error-keys";
5
-
6
- describe("mfaManageErrorKey", () => {
7
- for (const code of Object.values(AuthMfaErrorCodes)) {
8
- test(`${code} maps to a key registered in en/de translations`, () => {
9
- const key = mfaManageErrorKey(code);
10
- expect(defaultTranslations["en"]?.[key]).toBeDefined();
11
- expect(defaultTranslations["de"]?.[key]).toBeDefined();
12
- });
13
- }
14
-
15
- test("unknown code falls back to the generic verifyFailed key", () => {
16
- const key = mfaManageErrorKey("some_future_server_code");
17
- expect(key).toBe("auth.mfa.errors.verifyFailed");
18
- expect(defaultTranslations["en"]?.[key]).toBeDefined();
19
- });
20
- });
@@ -1,29 +0,0 @@
1
- // @runtime client
2
- // Shared error-code → i18n-key mapping for the account-management write
3
- // handlers (enable-confirm/disable/regenerate-recovery) — distinct from
4
- // mfa-verify-screen.tsx's own mapper, which covers the login-challenge
5
- // error surface (challenge_expired/rate_limited) that these handlers never
6
- // return. Centralized so enable/disable/regenerate stay in sync instead of
7
- // each re-deriving the key ad hoc (enable-screen used to template-string
8
- // the raw snake_case code straight into the key, which never matched the
9
- // camelCase keys actually registered below).
10
- import { AuthMfaErrorCodes } from "../constants";
11
-
12
- export function mfaManageErrorKey(code: string): string {
13
- switch (code) {
14
- case AuthMfaErrorCodes.invalidTotpCode:
15
- return "auth.mfa.errors.invalidCode";
16
- case AuthMfaErrorCodes.invalidRecoveryCode:
17
- return "auth.mfa.errors.invalidRecoveryCode";
18
- case AuthMfaErrorCodes.mfaAlreadyEnabled:
19
- return "auth.mfa.errors.mfaAlreadyEnabled";
20
- case AuthMfaErrorCodes.mfaNotEnabled:
21
- return "auth.mfa.errors.mfaNotEnabled";
22
- case AuthMfaErrorCodes.invalidSetupToken:
23
- return "auth.mfa.errors.invalidSetupToken";
24
- case AuthMfaErrorCodes.setupFailed:
25
- return "auth.mfa.errors.setupFailed";
26
- default:
27
- return "auth.mfa.errors.verifyFailed";
28
- }
29
- }