@cosmicdrift/kumiko-bundled-features 0.215.0 → 0.215.1
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.215.
|
|
3
|
+
"version": "0.215.1",
|
|
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.215.
|
|
130
|
-
"@cosmicdrift/kumiko-framework": "0.215.
|
|
131
|
-
"@cosmicdrift/kumiko-headless": "0.215.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer": "0.215.
|
|
133
|
-
"@cosmicdrift/kumiko-renderer-web": "0.215.
|
|
134
|
-
"@cosmicdrift/kumiko-types": "0.215.
|
|
129
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.215.1",
|
|
130
|
+
"@cosmicdrift/kumiko-framework": "0.215.1",
|
|
131
|
+
"@cosmicdrift/kumiko-headless": "0.215.1",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer": "0.215.1",
|
|
133
|
+
"@cosmicdrift/kumiko-renderer-web": "0.215.1",
|
|
134
|
+
"@cosmicdrift/kumiko-types": "0.215.1",
|
|
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.215.
|
|
163
|
+
"@cosmicdrift/kumiko-locale-de": "0.215.1"
|
|
164
164
|
}
|
|
165
165
|
}
|
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
16
16
|
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
17
17
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
18
|
+
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
18
19
|
import {
|
|
19
20
|
createEntity,
|
|
21
|
+
createSystemUser,
|
|
20
22
|
createTextField,
|
|
21
23
|
defineFeature,
|
|
22
24
|
EXT_USER_DATA,
|
|
@@ -38,6 +40,8 @@ import { buildEnvConfigOverrides, createConfigResolver } from "../../config/reso
|
|
|
38
40
|
import { configValueEntity } from "../../config/table";
|
|
39
41
|
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
40
42
|
import { createSessionsFeature } from "../../sessions";
|
|
43
|
+
import { tenantMembershipEntity, tenantMembershipsTable } from "../../tenant";
|
|
44
|
+
import { seedTenantMembership } from "../../tenant/seeding";
|
|
41
45
|
import { createUserFeature, userEntity } from "../../user";
|
|
42
46
|
import { TENANT_MODEL_CONFIG_KEY } from "../constants";
|
|
43
47
|
import { createUserDataRightsFeature } from "../feature";
|
|
@@ -74,6 +78,14 @@ const contributorFeature = defineFeature("dsgvo-tenant-scoped", (r) => {
|
|
|
74
78
|
});
|
|
75
79
|
});
|
|
76
80
|
|
|
81
|
+
const membershipExecutor = createEventStoreExecutor(
|
|
82
|
+
tenantMembershipsTable,
|
|
83
|
+
tenantMembershipEntity,
|
|
84
|
+
{
|
|
85
|
+
entityName: "tenant-membership",
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
|
|
77
89
|
let stack: TestStack;
|
|
78
90
|
const seed = (db: unknown) =>
|
|
79
91
|
// biome-ignore lint/suspicious/noExplicitAny: dummy writer; this contributor has no binaries.
|
|
@@ -224,6 +236,69 @@ describe("forget pipeline honours the effective tenant model", () => {
|
|
|
224
236
|
expect(result.errors).toHaveLength(0);
|
|
225
237
|
expect(await rowCount()).toBe(1);
|
|
226
238
|
});
|
|
239
|
+
|
|
240
|
+
test("single-user, sole live member, but a departed co-member's history remains → rows preserved (ent#346)", async () => {
|
|
241
|
+
await seedScopedRow("dddddddd-dddd-4ddd-8ddd-0000000000c5");
|
|
242
|
+
await seed(stack.db).seedForgetUser(FORGET_USER);
|
|
243
|
+
// Both memberships go through the REAL event-store executor (not the
|
|
244
|
+
// raw-INSERT seedMembership() helper) — the historical check reads
|
|
245
|
+
// `tenant-membership.created` events, so FORGET_USER needs its own
|
|
246
|
+
// `.created` event too, or the tenant would only ever show 1 distinct
|
|
247
|
+
// historical member (CO_MEMBER) even after it truly had 2.
|
|
248
|
+
// Explicit `by`: streamTenantFor(user) keys each event's OWN tenantId
|
|
249
|
+
// column off the acting user, not the payload's tenantId — the
|
|
250
|
+
// seedTenantMembership default (TestUsers.systemAdmin) lives on a
|
|
251
|
+
// different tenant, which would emit .created under the wrong tenantId
|
|
252
|
+
// and make it invisible to everHadMultipleMembers' tenant-scoped query.
|
|
253
|
+
await seedTenantMembership(stack.db, {
|
|
254
|
+
userId: FORGET_USER,
|
|
255
|
+
tenantId: TENANT,
|
|
256
|
+
roles: ["Member"],
|
|
257
|
+
by: createSystemUser(TENANT),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// CO_MEMBER joins for real (emits tenant-membership.created) and is then
|
|
261
|
+
// removed for real (emits tenant-membership.deleted, deletes the
|
|
262
|
+
// projection row) — mirrors removeMemberWrite exactly, so the created
|
|
263
|
+
// event survives in kumiko_events even though the live row is gone.
|
|
264
|
+
const created = await seedTenantMembership(stack.db, {
|
|
265
|
+
userId: CO_MEMBER,
|
|
266
|
+
tenantId: TENANT,
|
|
267
|
+
roles: ["Member"],
|
|
268
|
+
by: createSystemUser(TENANT),
|
|
269
|
+
});
|
|
270
|
+
const deleteResult = await membershipExecutor.delete(
|
|
271
|
+
{ id: created.id },
|
|
272
|
+
createSystemUser(TENANT),
|
|
273
|
+
createTenantDb(stack.db, TENANT, "system"),
|
|
274
|
+
);
|
|
275
|
+
if (!deleteResult.isSuccess) {
|
|
276
|
+
throw new Error(`test setup: co-member delete failed: ${deleteResult.error.code}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Pins the test's premise: exactly 1 live membership row, so a live-count-
|
|
280
|
+
// only check would call this tenant single-user (and the fix must reject
|
|
281
|
+
// that call via history instead).
|
|
282
|
+
const liveMemberships = await asRawClient(stack.db).unsafe(
|
|
283
|
+
"SELECT id FROM read_tenant_memberships WHERE tenant_id = $1",
|
|
284
|
+
[TENANT],
|
|
285
|
+
);
|
|
286
|
+
expect(liveMemberships.length).toBe(1);
|
|
287
|
+
|
|
288
|
+
const result = await runForgetCleanup({
|
|
289
|
+
db: stack.db,
|
|
290
|
+
registry: stack.registry,
|
|
291
|
+
now: nowInstant(),
|
|
292
|
+
tenantModel: "single-user",
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
expect(result.errors).toHaveLength(0);
|
|
296
|
+
expect(result.processedUserIds).toContain(FORGET_USER);
|
|
297
|
+
// Only 1 live membership remains (FORGET_USER) — the OLD live-only check
|
|
298
|
+
// would have called this single-user and wiped the co-member's row. The
|
|
299
|
+
// historical check must still resolve multi-user here.
|
|
300
|
+
expect(await rowCount()).toBe(1);
|
|
301
|
+
});
|
|
227
302
|
});
|
|
228
303
|
|
|
229
304
|
describe("run-forget-cleanup job — real glue-code, not a hand-set tenantModel", () => {
|
|
@@ -38,7 +38,11 @@ import {
|
|
|
38
38
|
type KmsAdapter,
|
|
39
39
|
subjectIdToKey,
|
|
40
40
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
41
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
type DbRunner,
|
|
43
|
+
entityEventName,
|
|
44
|
+
nullBlindIndexesForSubject,
|
|
45
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
42
46
|
import {
|
|
43
47
|
EXT_USER_DATA,
|
|
44
48
|
EXT_USER_DATA_ORDER,
|
|
@@ -49,6 +53,7 @@ import {
|
|
|
49
53
|
type UserDataDeleteStrategy,
|
|
50
54
|
type UserDataStorageProvider,
|
|
51
55
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
56
|
+
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
52
57
|
import {
|
|
53
58
|
purgeSearchDocumentsForSubject,
|
|
54
59
|
type SearchAdapter,
|
|
@@ -554,7 +559,37 @@ async function resolveEffectiveTenantModel(
|
|
|
554
559
|
{ tenantId },
|
|
555
560
|
{ limit: 2 },
|
|
556
561
|
);
|
|
557
|
-
|
|
562
|
+
if (members.length !== 1) return "multi-user";
|
|
563
|
+
return (await everHadMultipleMembers(db, tenantId)) ? "multi-user" : "single-user";
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// removeMemberWrite only deletes the tenant-membership PROJECTION row — the
|
|
567
|
+
// event history survives forever, and a departed member's own tenant-scoped
|
|
568
|
+
// data rows (contributor-style records with no per-user column) can
|
|
569
|
+
// outlive their membership (ent#346). A live count of 1 is therefore not
|
|
570
|
+
// enough: a tenant that ever had >1 distinct member must stay multi-user for
|
|
571
|
+
// the forget path even after everyone but one is removed, or the sole
|
|
572
|
+
// remaining member's forget request wipes the departed co-member's leftover
|
|
573
|
+
// rows. LIMIT chosen generously for a nominally single-user tenant; hitting
|
|
574
|
+
// it is itself a multi-user signal, so it also resolves to "multi-user".
|
|
575
|
+
const MAX_MEMBERSHIP_CREATED_EVENTS = 1000;
|
|
576
|
+
|
|
577
|
+
async function everHadMultipleMembers(db: DbRunner, tenantId: TenantId): Promise<boolean> {
|
|
578
|
+
const createdEvents = await selectMany<{ payload: Record<string, unknown> }>(
|
|
579
|
+
db,
|
|
580
|
+
eventsTable,
|
|
581
|
+
{
|
|
582
|
+
tenantId,
|
|
583
|
+
aggregateType: "tenant-membership",
|
|
584
|
+
type: entityEventName("tenant-membership", "created"),
|
|
585
|
+
},
|
|
586
|
+
{ limit: MAX_MEMBERSHIP_CREATED_EVENTS },
|
|
587
|
+
);
|
|
588
|
+
if (createdEvents.length >= MAX_MEMBERSHIP_CREATED_EVENTS) return true;
|
|
589
|
+
const distinctUserIds = new Set(
|
|
590
|
+
createdEvents.map((e) => e.payload["userId"]).filter((v): v is string => typeof v === "string"),
|
|
591
|
+
);
|
|
592
|
+
return distinctUserIds.size > 1;
|
|
558
593
|
}
|
|
559
594
|
|
|
560
595
|
// Mapping retention.strategy → user-data-rights.UserDataDeleteStrategy.
|