@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 +6 -6
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +217 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +16 -0
- package/src/folders/web/folder-manager.tsx +1 -0
- package/src/tenant/handlers/invitations.query.ts +17 -13
- package/src/tenant/handlers/members.query.ts +30 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.157.
|
|
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.
|
|
120
|
-
"@cosmicdrift/kumiko-framework": "0.157.
|
|
121
|
-
"@cosmicdrift/kumiko-headless": "0.157.
|
|
122
|
-
"@cosmicdrift/kumiko-renderer": "0.157.
|
|
123
|
-
"@cosmicdrift/kumiko-renderer-web": "0.157.
|
|
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(
|
|
@@ -22,18 +22,22 @@ export const invitationsQuery = defineQueryHandler({
|
|
|
22
22
|
tenantId: query.user.tenantId,
|
|
23
23
|
status: INVITATION_STATUS.pending,
|
|
24
24
|
});
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
});
|