@cosmicdrift/kumiko-bundled-features 0.215.0 → 0.215.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.215.0",
3
+ "version": "0.215.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>",
@@ -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.2",
130
+ "@cosmicdrift/kumiko-framework": "0.215.2",
131
+ "@cosmicdrift/kumiko-headless": "0.215.2",
132
+ "@cosmicdrift/kumiko-renderer": "0.215.2",
133
+ "@cosmicdrift/kumiko-renderer-web": "0.215.2",
134
+ "@cosmicdrift/kumiko-types": "0.215.2",
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.2"
164
164
  }
165
165
  }
@@ -40,6 +40,7 @@ import { apiTokenEntity, apiTokenTable } from "../../personal-access-tokens/sche
40
40
  import { createTenantFeature } from "../../tenant";
41
41
  import { tenantInvitationEntity } from "../../tenant/invitation-table";
42
42
  import { tenantMembershipsTable } from "../../tenant/membership-table";
43
+ import { seedTenantMembership } from "../../tenant/seeding";
43
44
  import { USER_STATUS, userEntity, userTable } from "../../user";
44
45
  import { createUserFeature } from "../../user/feature";
45
46
  import { seedUser } from "../../user/seeding";
@@ -116,20 +117,47 @@ describe("crypto-shredding :: forget-subject", () => {
116
117
  });
117
118
  });
118
119
 
119
- test("tenant subject shreds the same way", async () => {
120
- const subject = { kind: "tenant", tenantId: TARGET_TENANT_ID } as const;
121
- await kms.createKey({ kind: "tenant", tenantId: TARGET_TENANT_ID as TenantId });
120
+ test("tenant subject shreds the same way (DPO's own tenant)", async () => {
121
+ const subject = { kind: "tenant", tenantId: TENANT } as const;
122
+ await kms.createKey({ kind: "tenant", tenantId: TENANT });
122
123
 
123
124
  const result = await stack.http.writeOk<{ subjectKey: string }>(
124
125
  FORGET,
125
126
  { subject, reason: REASON },
126
127
  dpoUser,
127
128
  );
128
- expect(result.subjectKey).toBe(`tenant:${TARGET_TENANT_ID}`);
129
+ expect(result.subjectKey).toBe(`tenant:${TENANT}`);
130
+
131
+ await expect(kms.getKey({ kind: "tenant", tenantId: TENANT })).rejects.toThrow(
132
+ "Subject key erased",
133
+ );
134
+ });
135
+
136
+ // mh#349: DataProtectionOfficer is tenant-scoped — a DPO who learns a
137
+ // foreign tenant's id (export, support ticket, log line) must not be able
138
+ // to destroy that tenant's subject key.
139
+ test("DPO cannot forget a different tenant's subject → 403", async () => {
140
+ const subject = { kind: "tenant", tenantId: TARGET_TENANT_ID } as const;
141
+ await kms.createKey({ kind: "tenant", tenantId: TARGET_TENANT_ID as TenantId });
142
+
143
+ const err = await stack.http.writeErr(FORGET, { subject, reason: REASON }, dpoUser);
144
+ expect(err.httpStatus).toBe(403);
129
145
 
130
146
  await expect(
131
147
  kms.getKey({ kind: "tenant", tenantId: TARGET_TENANT_ID as TenantId }),
132
- ).rejects.toThrow("Subject key erased");
148
+ ).resolves.toBeTruthy();
149
+ });
150
+
151
+ test("SystemAdmin bypasses the tenant-scope guard", async () => {
152
+ const subject = { kind: "tenant", tenantId: TARGET_TENANT_ID } as const;
153
+ await kms.createKey({ kind: "tenant", tenantId: TARGET_TENANT_ID as TenantId });
154
+
155
+ const result = await stack.http.writeOk<{ subjectKey: string }>(
156
+ FORGET,
157
+ { subject, reason: REASON },
158
+ TestUsers.systemAdmin,
159
+ );
160
+ expect(result.subjectKey).toBe(`tenant:${TARGET_TENANT_ID}`);
133
161
  });
134
162
 
135
163
  test("repeat forget: erase is a no-op but each attempt is audited", async () => {
@@ -307,6 +335,31 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
307
335
  let kms: InMemoryKmsAdapter;
308
336
  const TENANT_B = testTenantId(3);
309
337
 
338
+ // mh#349: a "user"-kind subject isn't always a real user — a share-token
339
+ // recipient or email subscriber self-owns its PII the same way
340
+ // (personal: "self", its own row id is the subject) but has no tenant-
341
+ // membership row. The guard must fall back to the subject row's own
342
+ // tenant_id for these.
343
+ const shareLikeEntity = createEntity({
344
+ table: "read_forget_subject_share_like_probe",
345
+ fields: {
346
+ recipientName: createTextField({
347
+ required: true,
348
+ maxLength: 100,
349
+ personal: "self",
350
+ find: "exact",
351
+ }),
352
+ },
353
+ });
354
+ const shareLikeTable = buildEntityTable("forgetSubjectShareLikeProbe", shareLikeEntity);
355
+ const shareLikeFeature = defineFeature("forget-subject-share-like-probe", (r) => {
356
+ r.entity("probe", shareLikeEntity);
357
+ });
358
+
359
+ function shareLikeProbeExecutor() {
360
+ return createEventStoreExecutor(shareLikeTable, shareLikeEntity, { entityName: "probe" });
361
+ }
362
+
310
363
  beforeAll(async () => {
311
364
  stack = await setupTestStack({
312
365
  features: [
@@ -316,6 +369,7 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
316
369
  createConfigFeature(),
317
370
  authFoundationFeature,
318
371
  createPersonalAccessTokensFeature({ scopes: {} }),
372
+ shareLikeFeature,
319
373
  ],
320
374
  });
321
375
  await unsafeCreateEntityTable(stack.db, userEntity);
@@ -323,6 +377,7 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
323
377
  // tenant feature's only lookupable entity — the blind-index sweep
324
378
  // touches it; without the table the handler 500s on `read_tenant_invitations`.
325
379
  await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
380
+ await unsafeCreateEntityTable(stack.db, shareLikeEntity, "probe");
326
381
  await unsafePushTables(stack.db, { tenantMembershipsTable });
327
382
  await createEventsTable(stack.db);
328
383
  });
@@ -336,6 +391,7 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
336
391
  userTable,
337
392
  apiTokenTable,
338
393
  tenantMembershipsTable,
394
+ shareLikeTable,
339
395
  eventsTable,
340
396
  ]);
341
397
  kms = new InMemoryKmsAdapter();
@@ -352,6 +408,7 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
352
408
  displayName: "Forgotten User",
353
409
  emailVerified: true,
354
410
  });
411
+ await seedTenantMembership(stack.db, { userId, tenantId: TENANT, roles: ["Member"] });
355
412
  // No explicit createKey: seedUser's PII-encrypt (email) already created
356
413
  // the subject key implicitly via getOrCreateDek.
357
414
  await insertOne(stack.db, apiTokenTable, {
@@ -378,4 +435,84 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
378
435
  expect(tokens).toHaveLength(1);
379
436
  expect(tokens[0]?.["revokedAt"]).not.toBeNull();
380
437
  });
438
+
439
+ // mh#349: a DPO must not be able to forget a user who has no membership in
440
+ // their own tenant (e.g. a Tenant-A DPO who learned a Tenant-B user id).
441
+ test("DPO cannot forget a user with no membership in their tenant → 403", async () => {
442
+ const { id: userId } = await seedUser(stack.db, {
443
+ email: "foreign-tenant-user@example.com",
444
+ displayName: "Foreign Tenant User",
445
+ emailVerified: true,
446
+ });
447
+ await seedTenantMembership(stack.db, { userId, tenantId: TENANT_B, roles: ["Member"] });
448
+
449
+ const err = await stack.http.writeErr(
450
+ FORGET,
451
+ { subject: { kind: "user", userId }, reason: REASON },
452
+ dpoUser,
453
+ );
454
+ expect(err.httpStatus).toBe(403);
455
+
456
+ const userRow = await fetchOne<Record<string, unknown>>(stack.db, userTable, { id: userId });
457
+ expect(userRow?.["status"]).not.toBe(USER_STATUS.Deleted);
458
+ });
459
+
460
+ test("SystemAdmin can forget a user with no membership in any tenant", async () => {
461
+ const { id: userId } = await seedUser(stack.db, {
462
+ email: "system-admin-forgets@example.com",
463
+ displayName: "System Admin Forgets",
464
+ emailVerified: true,
465
+ });
466
+
467
+ await stack.http.writeOk(
468
+ FORGET,
469
+ { subject: { kind: "user", userId }, reason: REASON },
470
+ TestUsers.systemAdmin,
471
+ );
472
+
473
+ const userRow = await fetchOne<Record<string, unknown>>(stack.db, userTable, { id: userId });
474
+ expect(userRow?.["status"]).toBe(USER_STATUS.Deleted);
475
+ });
476
+
477
+ test("DPO can forget a self-owned PII subject (share-token-like) in their own tenant", async () => {
478
+ const created = await shareLikeProbeExecutor().create(
479
+ { recipientName: "Tommy Recipient" },
480
+ dpoUser,
481
+ createTenantDb(stack.db, TENANT, "system"),
482
+ );
483
+ if (!created.isSuccess) throw new Error("create failed");
484
+ const subjectId = String(created.data.id);
485
+
486
+ const result = await stack.http.writeOk<{ subjectKey: string }>(
487
+ FORGET,
488
+ { subject: { kind: "user", userId: subjectId }, reason: REASON },
489
+ dpoUser,
490
+ );
491
+ expect(result.subjectKey).toBe(`user:${subjectId}`);
492
+ });
493
+
494
+ test("DPO cannot forget another tenant's self-owned PII subject → 403", async () => {
495
+ // create()'s event stream is tenant-scoped to the AUTHOR, not the
496
+ // tenantDb override — an actor whose own tenantId is TENANT_B is
497
+ // required to actually seed the row under the foreign tenant.
498
+ const foreignTenantAuthor = {
499
+ id: "aaaaaaaa-aaaa-4aaa-8aaa-0000000000f2",
500
+ tenantId: TENANT_B,
501
+ roles: ["Member"],
502
+ };
503
+ const created = await shareLikeProbeExecutor().create(
504
+ { recipientName: "Peter Recipient" },
505
+ foreignTenantAuthor,
506
+ createTenantDb(stack.db, TENANT_B, "system"),
507
+ );
508
+ if (!created.isSuccess) throw new Error("create failed");
509
+ const subjectId = String(created.data.id);
510
+
511
+ const err = await stack.http.writeErr(
512
+ FORGET,
513
+ { subject: { kind: "user", userId: subjectId }, reason: REASON },
514
+ dpoUser,
515
+ );
516
+ expect(err.httpStatus).toBe(403);
517
+ });
381
518
  });
@@ -3,3 +3,5 @@ export const CRYPTO_SHREDDING_FEATURE_NAME = "crypto-shredding";
3
3
  export const SUBJECT_FORGOTTEN_EVENT_NAME = "crypto-shredding:event:subject-forgotten";
4
4
 
5
5
  export const CRYPTO_SHREDDING_AGGREGATE_TYPE = "crypto-shredding-subject";
6
+
7
+ export const TARGET_TENANT_NOT_ADMIN_TENANT = "target_tenant_not_admin_tenant" as const;
@@ -5,15 +5,37 @@ import {
5
5
  type SubjectId,
6
6
  subjectIdToKey,
7
7
  } from "@cosmicdrift/kumiko-framework/crypto";
8
- import { nullBlindIndexesForSubject } from "@cosmicdrift/kumiko-framework/db";
9
- import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
10
- import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
8
+ import {
9
+ type DbRunner,
10
+ nullBlindIndexesForSubject,
11
+ subjectRowExistsInTenant,
12
+ } from "@cosmicdrift/kumiko-framework/db";
13
+ import {
14
+ defineWriteHandler,
15
+ type FeatureDefinition,
16
+ type SessionUser,
17
+ type TenantId,
18
+ } from "@cosmicdrift/kumiko-framework/engine";
19
+ import {
20
+ AccessDeniedError,
21
+ InternalError,
22
+ type WriteFailure,
23
+ writeFailure,
24
+ } from "@cosmicdrift/kumiko-framework/errors";
11
25
  import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/search";
12
26
  import { z } from "zod";
13
27
  import { revokeAllPatTokensForUser } from "../../personal-access-tokens";
14
28
  import { USER_STATUS } from "../../user";
15
- import { updateUserLifecycle } from "../../user-data-rights";
16
- import { CRYPTO_SHREDDING_AGGREGATE_TYPE, SUBJECT_FORGOTTEN_EVENT_NAME } from "../constants";
29
+ import {
30
+ denyIfTargetOutsideAdminTenant,
31
+ isSystemAdminActor,
32
+ updateUserLifecycle,
33
+ } from "../../user-data-rights";
34
+ import {
35
+ CRYPTO_SHREDDING_AGGREGATE_TYPE,
36
+ SUBJECT_FORGOTTEN_EVENT_NAME,
37
+ TARGET_TENANT_NOT_ADMIN_TENANT,
38
+ } from "../constants";
17
39
 
18
40
  export const subjectIdSchema = z.discriminatedUnion("kind", [
19
41
  z.object({ kind: z.literal("user"), userId: z.uuid() }),
@@ -31,6 +53,43 @@ export const subjectForgottenSchema = z.object({
31
53
  forgottenBy: z.string().min(1),
32
54
  });
33
55
 
56
+ type SubjectIdInput = z.infer<typeof subjectIdSchema>;
57
+
58
+ // Tenant-scope guard (mh#349): DataProtectionOfficer is a tenant-scoped role,
59
+ // but the handler otherwise erases ANY subject cross-tenant on a raw
60
+ // (un-scoped) client — a Tenant-A DPO who learns a Tenant-B subject id
61
+ // (export, support ticket, log line) could destroy it. SystemAdmin
62
+ // (platform-wide) always bypasses.
63
+ async function resolveTenantScopeDenial(
64
+ db: DbRunner,
65
+ features: ReadonlyMap<string, FeatureDefinition>,
66
+ user: SessionUser,
67
+ raw: SubjectIdInput,
68
+ ): Promise<WriteFailure | undefined> {
69
+ if (isSystemAdminActor(user)) return undefined;
70
+
71
+ if (raw.kind === "tenant") {
72
+ if (raw.tenantId === user.tenantId) return undefined;
73
+ return writeFailure(
74
+ new AccessDeniedError({ details: { reason: TARGET_TENANT_NOT_ADMIN_TENANT } }),
75
+ );
76
+ }
77
+
78
+ // Without the tenant feature there's no membership table to check against —
79
+ // no scoping concept exists to enforce (single/no-tenant apps only;
80
+ // ponytail: fail-open here, not a gap in multi-tenant apps).
81
+ if (!features.has("tenant")) return undefined;
82
+
83
+ const memberDenied = await denyIfTargetOutsideAdminTenant(db, user, raw.userId);
84
+ if (!memberDenied) return undefined;
85
+
86
+ // Not a real user in the actor's tenant — raw.userId may still be a
87
+ // self-owned PII subject (share-token recipient, email subscriber, ...)
88
+ // whose own row carries a real tenant_id.
89
+ const ownedInTenant = await subjectRowExistsInTenant(db, features, raw.userId, user.tenantId);
90
+ return ownedInTenant ? undefined : memberDenied;
91
+ }
92
+
34
93
  // Manual crypto-shred for a DPO / platform operator: erases the subject's
35
94
  // DEK immediately (all its PII ciphertext becomes unreadable, reads render
36
95
  // "[[erased]]") and appends the audit event. Forget is final — the adapter
@@ -63,6 +122,14 @@ export const forgetSubjectWrite = defineWriteHandler({
63
122
  : { kind: "tenant", tenantId: raw.tenantId as TenantId }; // @cast-boundary uuid-validated command payload → branded id
64
123
  const subjectKey = subjectIdToKey(subject);
65
124
 
125
+ const tenantScopeDenial = await resolveTenantScopeDenial(
126
+ ctx.db.raw,
127
+ ctx.registry.features,
128
+ event.user,
129
+ raw,
130
+ );
131
+ if (tenantScopeDenial) return tenantScopeDenial;
132
+
66
133
  // Erase BEFORE the audit append: if the append throws, the key is gone
67
134
  // but no event exists — a retry is a no-op erase plus the event. The
68
135
  // reverse order could leave an audit trail claiming a shred that never
@@ -55,4 +55,42 @@ describe("tenant-settings — Settings-Hub nav/section labels", () => {
55
55
  if (sectionTitle === undefined) throw new Error("unreachable");
56
56
  expect(translate(translations, sectionTitle)).not.toBe(sectionTitle);
57
57
  });
58
+
59
+ test("every Settings-Hub label the schema renders resolves in es, distinct from en", () => {
60
+ const registry = createRegistry([createConfigFeature(), createTenantSettingsFeature()]);
61
+ const schema = buildConfigFeatureSchema(registry);
62
+ const translations = registry.getFeature("tenant-settings")?.translations ?? {};
63
+
64
+ const renderedKeys: string[] = [];
65
+ for (const screenId of ["tenant-settings-system", "tenant-settings-tenant"]) {
66
+ const nav = schema.navs.find((n) => n.id === screenId);
67
+ const screen = schema.screens.find((s) => s.id === screenId);
68
+ expect(nav).toBeDefined();
69
+ expect(screen).toBeDefined();
70
+ if (nav === undefined || screen === undefined || screen.type !== "configEdit") {
71
+ throw new Error("unreachable");
72
+ }
73
+ const section = screen.layout.sections[0];
74
+ const sectionTitle = section !== undefined && "title" in section ? section.title : undefined;
75
+ expect(sectionTitle).toBeDefined();
76
+ if (sectionTitle === undefined) throw new Error("unreachable");
77
+
78
+ renderedKeys.push(nav.label, `screen:${screenId}.title`, sectionTitle);
79
+ renderedKeys.push(...Object.values(screen.fieldLabels ?? {}));
80
+ }
81
+
82
+ // The Settings-Hub renders exactly these label surfaces per scope: nav
83
+ // entry, screen title, section heading and one label per masked config
84
+ // key (currency + locale) — six distinct keys across both scopes.
85
+ expect(new Set(renderedKeys).size).toBe(6);
86
+
87
+ for (const key of renderedKeys) {
88
+ expect(translate(translations, key, "es")).not.toBe(key);
89
+ expect(translate(translations, key, "es")).not.toBe(translate(translations, key, "en"));
90
+ }
91
+
92
+ const systemLabel = translate(translations, "tenant-settings.settings.system", "es");
93
+ const tenantLabel = translate(translations, "tenant-settings.settings", "es");
94
+ expect(systemLabel).not.toBe(tenantLabel);
95
+ });
58
96
  });
@@ -29,21 +29,35 @@ export function createTenantSettingsFeature(
29
29
  r.config({ keys: buildTenantSettingsKeys(opts) });
30
30
  r.translations({
31
31
  keys: {
32
- "tenant-settings.currency": { de: "Standard-Währung", en: "Default Currency" },
33
- "tenant-settings.locale": { de: "Standard-Sprache", en: "Default Locale" },
32
+ "tenant-settings.currency": {
33
+ de: "Standard-Währung",
34
+ en: "Default Currency",
35
+ es: "Moneda predeterminada",
36
+ },
37
+ "tenant-settings.locale": {
38
+ de: "Standard-Sprache",
39
+ en: "Default Locale",
40
+ es: "Idioma predeterminado",
41
+ },
34
42
  // Settings-Hub-derived configEdit screen title (buildConfigFeatureSchema) —
35
43
  // required by the i18n boot-validator whenever a feature masks config keys.
36
44
  "screen:tenant-settings-system.title": {
37
45
  de: "Tenant-Einstellungen",
38
46
  en: "Tenant Settings",
47
+ es: "Ajustes de la organización",
39
48
  },
40
49
  "screen:tenant-settings-tenant.title": {
41
50
  de: "Tenant-Einstellungen",
42
51
  en: "Tenant Settings",
52
+ es: "Ajustes de la organización",
43
53
  },
44
54
  // Settings-Hub nav label + configEdit section heading (same key, both
45
55
  // scopes fall back here — see buildConfigFeatureSchema).
46
- "tenant-settings.settings": { de: "Tenant-Einstellungen", en: "Tenant Settings" },
56
+ "tenant-settings.settings": {
57
+ de: "Tenant-Einstellungen",
58
+ en: "Tenant Settings",
59
+ es: "Ajustes de la organización",
60
+ },
47
61
  // Both currency/locale are tenant-home with admin write access, which
48
62
  // cascades a SystemAdmin-only "set the platform default" screen up to
49
63
  // the system audience — scoped override so its nav entry reads
@@ -52,6 +66,7 @@ export function createTenantSettingsFeature(
52
66
  "tenant-settings.settings.system": {
53
67
  de: "Tenant-Einstellungen (Plattform-Standard)",
54
68
  en: "Tenant Settings (Platform Default)",
69
+ es: "Ajustes de la organización (valor predeterminado de la plataforma)",
55
70
  },
56
71
  },
57
72
  });
@@ -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", () => {
@@ -1,5 +1,10 @@
1
1
  export { createUserDataRightsFeature, type UserDataRightsOptions } from "./feature";
2
2
  export type { SendDeletionVerificationEmailFn } from "./handlers/request-deletion-by-email.write";
3
+ export {
4
+ denyIfTargetOutsideAdminTenant,
5
+ TARGET_USER_NOT_IN_ADMIN_TENANT,
6
+ } from "./lib/deny-if-target-outside-admin-tenant";
7
+ export { isSystemAdminActor } from "./lib/is-admin-actor";
3
8
  // #494 Bestandsdaten-Reconcile — Apps rufen das einmalig vor dem Re-Enable
4
9
  // von read_users-Rebuilds (siehe lib-Doc).
5
10
  export { backfillUserLifecycleEvents, updateUserLifecycle } from "./lib/update-user-lifecycle";
@@ -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.