@cosmicdrift/kumiko-bundled-features 0.157.1 → 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.1",
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.1",
120
- "@cosmicdrift/kumiko-framework": "0.157.1",
121
- "@cosmicdrift/kumiko-headless": "0.157.1",
122
- "@cosmicdrift/kumiko-renderer": "0.157.1",
123
- "@cosmicdrift/kumiko-renderer-web": "0.157.1",
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,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,18 +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
- const invitedBy = row["invitedBy"];
29
- const decryptedEmail =
30
- typeof email === "string" ? await decryptStoredPii(email, "tenant:invitations") : email;
31
- const decryptedInvitedBy =
32
- typeof invitedBy === "string"
33
- ? await decryptStoredPii(invitedBy, "tenant:invitations")
34
- : invitedBy;
35
- return { ...row, email: decryptedEmail, invitedBy: decryptedInvitedBy };
36
- }),
37
- );
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;
38
42
  },
39
43
  });
@@ -22,24 +22,35 @@ export const membersQuery = defineQueryHandler({
22
22
  userIds.length > 0 ? await selectMany<UserRow>(ctx.db, userTable, { id: userIds }) : [];
23
23
  const userById = new Map(users.map((u) => [String(u.id), u]));
24
24
 
25
- return Promise.all(
26
- rows.map(async (row) => {
27
- const user = userById.get(String(row["userId"]));
28
- const email =
29
- typeof user?.email === "string"
30
- ? await decryptStoredPii(user.email, "tenant:members")
31
- : null;
32
- const displayName =
33
- typeof user?.displayName === "string"
34
- ? await decryptStoredPii(user.displayName, "tenant:members")
35
- : null;
36
- return {
37
- ...row,
38
- email,
39
- displayName,
40
- roles: parseRoles(row["roles"]),
41
- };
42
- }),
43
- );
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
+
45
+ return rows.map((row) => {
46
+ const user = userById.get(String(row["userId"]));
47
+ const decrypted = user ? decryptedByUserId.get(String(user.id)) : undefined;
48
+ return {
49
+ ...row,
50
+ email: decrypted?.email ?? null,
51
+ displayName: decrypted?.displayName ?? null,
52
+ roles: parseRoles(row["roles"]),
53
+ };
54
+ });
44
55
  },
45
56
  });