@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.0",
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.0",
130
- "@cosmicdrift/kumiko-framework": "0.215.0",
131
- "@cosmicdrift/kumiko-headless": "0.215.0",
132
- "@cosmicdrift/kumiko-renderer": "0.215.0",
133
- "@cosmicdrift/kumiko-renderer-web": "0.215.0",
134
- "@cosmicdrift/kumiko-types": "0.215.0",
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.0"
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 { type DbRunner, nullBlindIndexesForSubject } from "@cosmicdrift/kumiko-framework/db";
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
- return members.length === 1 ? "single-user" : "multi-user";
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.