@cosmicdrift/kumiko-bundled-features 0.249.0 → 0.251.0

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.249.0",
3
+ "version": "0.251.0",
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>",
@@ -130,12 +130,12 @@
130
130
  "./workflow-runner": "./src/workflow-runner/index.ts"
131
131
  },
132
132
  "dependencies": {
133
- "@cosmicdrift/kumiko-dispatcher-live": "0.249.0",
134
- "@cosmicdrift/kumiko-framework": "0.249.0",
135
- "@cosmicdrift/kumiko-headless": "0.249.0",
136
- "@cosmicdrift/kumiko-renderer": "0.249.0",
137
- "@cosmicdrift/kumiko-renderer-web": "0.249.0",
138
- "@cosmicdrift/kumiko-types": "0.249.0",
133
+ "@cosmicdrift/kumiko-dispatcher-live": "0.251.0",
134
+ "@cosmicdrift/kumiko-framework": "0.251.0",
135
+ "@cosmicdrift/kumiko-headless": "0.251.0",
136
+ "@cosmicdrift/kumiko-renderer": "0.251.0",
137
+ "@cosmicdrift/kumiko-renderer-web": "0.251.0",
138
+ "@cosmicdrift/kumiko-types": "0.251.0",
139
139
  "@mollie/api-client": "^4.5.0",
140
140
  "@node-rs/argon2": "^2.0.2",
141
141
  "@types/mailparser": "^3.4.6",
@@ -164,7 +164,7 @@
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
166
  "@types/qrcode": "^1.5.5",
167
- "@cosmicdrift/kumiko-locale-de": "0.249.0",
168
- "@cosmicdrift/kumiko-locale-es": "0.249.0"
167
+ "@cosmicdrift/kumiko-locale-de": "0.251.0",
168
+ "@cosmicdrift/kumiko-locale-es": "0.251.0"
169
169
  }
170
170
  }
@@ -353,3 +353,72 @@ describe("invite-accept-with-login: account-status gate", () => {
353
353
  expect(memberships).toHaveLength(0);
354
354
  });
355
355
  });
356
+
357
+ describe("invite-accept-with-login: soft-deleted email predecessor (fw#2593)", () => {
358
+ // read_users_email_unique is now a partial index (WHERE is_deleted = false),
359
+ // so a soft-deleted row and a live row can share an email. The password
360
+ // lookup must resolve the live row, never the soft-deleted one.
361
+ test("logs in as the live user, not the soft-deleted predecessor with the same email", async () => {
362
+ const email = "reused-invitee@example.com";
363
+ const deletedUserId = crypto.randomUUID();
364
+ const liveUserId = crypto.randomUUID();
365
+ const deletedPassword = "deleted-predecessor-pw-1234";
366
+ const livePassword = "live-successor-pw-5678";
367
+
368
+ await seedRow(stack.db, userTable, {
369
+ id: deletedUserId,
370
+ tenantId: TENANT_A_ID,
371
+ email,
372
+ passwordHash: await hashPassword(deletedPassword),
373
+ displayName: "Deleted Predecessor",
374
+ locale: "de",
375
+ emailVerified: true,
376
+ roles: "[]",
377
+ status: USER_STATUS.Active,
378
+ });
379
+ await asRawClient(stack.db).unsafe(
380
+ `UPDATE "${userTable.tableName}" SET is_deleted = true WHERE id = $1`,
381
+ [deletedUserId],
382
+ );
383
+
384
+ await seedRow(stack.db, userTable, {
385
+ id: liveUserId,
386
+ tenantId: TENANT_A_ID,
387
+ email,
388
+ passwordHash: await hashPassword(livePassword),
389
+ displayName: "Live Successor",
390
+ locale: "de",
391
+ emailVerified: true,
392
+ roles: "[]",
393
+ status: USER_STATUS.Active,
394
+ });
395
+
396
+ const token = await inviteEmail(email, "Editor");
397
+
398
+ // The deleted predecessor's password must never authenticate this
399
+ // invite, even though it shares the email — proves the lookup didn't
400
+ // just happen to land on the live row by insertion order. A failed
401
+ // password check unburns the token (see the handler's `finally`), so
402
+ // the same token is still usable for the real attempt below.
403
+ const deletedAttempt = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
404
+ token,
405
+ email,
406
+ password: deletedPassword,
407
+ });
408
+ expect(deletedAttempt.status).not.toBe(200);
409
+
410
+ const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
411
+ token,
412
+ email,
413
+ password: livePassword,
414
+ });
415
+
416
+ expect(res.status).toBe(200);
417
+ const body = (await res.json()) as {
418
+ isSuccess: boolean;
419
+ user?: { id: string };
420
+ };
421
+ expect(body.isSuccess).toBe(true);
422
+ expect(body.user?.id).toBe(liveUserId);
423
+ });
424
+ });
@@ -171,8 +171,11 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
171
171
 
172
172
  // Password check against userTable, through the same gates
173
173
  // login.write.ts runs (see login-gates.test.ts for the gate contracts).
174
+ // Email uniqueness is partial on live rows (framework#2593) — without
175
+ // this filter the lookup can resolve a soft-deleted row.
174
176
  const userRow = await fetchOne<UserAuthRow>(ctx.db.raw, userTable, {
175
177
  email: invitationEmail,
178
+ isDeleted: false,
176
179
  });
177
180
  if (!userRow?.passwordHash) return invalidInviteToken();
178
181
 
@@ -129,8 +129,11 @@ export function createInviteSignupCompleteHandler() {
129
129
  // muss der User Branch 2 (acceptWithLogin) nutzen. Hier ist
130
130
  // explizit "neue Email" — sonst hätten wir zwei Wege ein
131
131
  // Password zu setzen für denselben User.
132
+ // Email uniqueness is partial on live rows (framework#2593) — without
133
+ // this filter the lookup can resolve a soft-deleted row.
132
134
  const existingUser = await fetchOne(ctx.db.raw, userTable, {
133
135
  email: invitationEmail,
136
+ isDeleted: false,
134
137
  });
135
138
  if (existingUser) return invalidInviteToken();
136
139
 
@@ -86,11 +86,20 @@ export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE
86
86
  // shape (= subscription-state-snapshot); der event-type taggt was
87
87
  // passiert ist. Future-consumer (billing-history, accounting)
88
88
  // listenen direkt auf den event-type ohne payload-discriminator.
89
- r.defineEvent(SUBSCRIPTION_CREATED_EVENT_SHORT, subscriptionEventPayloadSchema);
90
- r.defineEvent(SUBSCRIPTION_UPDATED_EVENT_SHORT, subscriptionEventPayloadSchema);
91
- r.defineEvent(SUBSCRIPTION_CANCELED_EVENT_SHORT, subscriptionEventPayloadSchema);
92
- r.defineEvent(INVOICE_PAID_EVENT_SHORT, subscriptionEventPayloadSchema);
93
- r.defineEvent(INVOICE_PAYMENT_FAILED_EVENT_SHORT, subscriptionEventPayloadSchema);
89
+ // piiFields: "none" — provider ids are tenantOwned ciphertext, not plaintext personal data.
90
+ r.defineEvent(SUBSCRIPTION_CREATED_EVENT_SHORT, subscriptionEventPayloadSchema, {
91
+ piiFields: "none",
92
+ });
93
+ r.defineEvent(SUBSCRIPTION_UPDATED_EVENT_SHORT, subscriptionEventPayloadSchema, {
94
+ piiFields: "none",
95
+ });
96
+ r.defineEvent(SUBSCRIPTION_CANCELED_EVENT_SHORT, subscriptionEventPayloadSchema, {
97
+ piiFields: "none",
98
+ });
99
+ r.defineEvent(INVOICE_PAID_EVENT_SHORT, subscriptionEventPayloadSchema, { piiFields: "none" });
100
+ r.defineEvent(INVOICE_PAYMENT_FAILED_EVENT_SHORT, subscriptionEventPayloadSchema, {
101
+ piiFields: "none",
102
+ });
94
103
 
95
104
  // Inline projection: materialized current state in `read_subscriptions`.
96
105
  // Apply läuft in derselben TX wie ctx.unsafeAppendEvent — read-your-
@@ -84,7 +84,7 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
84
84
  // dasselbe Schema für Append-Time-Validation. QN nach Prefixing:
85
85
  // "cap-counter:event:rolling-incremented" (siehe
86
86
  // ROLLING_INCREMENTED_EVENT_QN).
87
- r.defineEvent(ROLLING_INCREMENTED_EVENT_SHORT, rollingIncrementedSchema);
87
+ r.defineEvent(ROLLING_INCREMENTED_EVENT_SHORT, rollingIncrementedSchema, { piiFields: "none" });
88
88
 
89
89
  // Custom write-handlers.
90
90
  // - increment: Calendar-Period (CRUD via projection-row).
@@ -16,8 +16,10 @@ export function createCryptoShreddingFeature(): FeatureDefinition {
16
16
  category: "compliance",
17
17
  });
18
18
 
19
- r.defineEvent("subject-forgotten", subjectForgottenSchema);
20
- r.defineEvent("forget-denied", subjectForgetDeniedSchema);
19
+ // `reason` is operator free text and may name the subject; encrypting it under
20
+ // the subject key is pointless (that key is being erased) — see #2776.
21
+ r.defineEvent("subject-forgotten", subjectForgottenSchema, { piiFields: "none" });
22
+ r.defineEvent("forget-denied", subjectForgetDeniedSchema, { piiFields: "none" });
21
23
  r.writeHandler(forgetSubjectWrite);
22
24
  });
23
25
  }
@@ -128,11 +128,18 @@ function registerCustomFields(
128
128
  // Event-types — qualified als "custom-fields:event:<short-name>".
129
129
  // Returned EventDefs liefern .name als compile-time literal-typed string,
130
130
  // den Handler + MSP-keys konsumieren statt Template-Literal-Konstruktion.
131
- const setEvent = r.defineEvent(CUSTOM_FIELD_SET_EVENT, customFieldSetSchema);
132
- const clearedEvent = r.defineEvent(CUSTOM_FIELD_CLEARED_EVENT, customFieldClearedSchema);
131
+ // `value: z.unknown()` has no field-level schema to catalog under piiFields;
132
+ // "no PII" is a naming convention (#972), not an enforced guarantee — see #2776.
133
+ const setEvent = r.defineEvent(CUSTOM_FIELD_SET_EVENT, customFieldSetSchema, {
134
+ piiFields: "none",
135
+ });
136
+ const clearedEvent = r.defineEvent(CUSTOM_FIELD_CLEARED_EVENT, customFieldClearedSchema, {
137
+ piiFields: "none",
138
+ });
133
139
  const fieldDefinitionDeletedEvent = r.defineEvent(
134
140
  FIELD_DEFINITION_DELETED_EVENT,
135
141
  fieldDefinitionDeletedSchema,
142
+ { piiFields: "none" },
136
143
  );
137
144
 
138
145
  // Extension-Registrar — registriert dass diese Extension existiert.
@@ -102,8 +102,14 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
102
102
  }),
103
103
  );
104
104
 
105
- r.defineEvent(DOCUMENT_INGEST_REQUESTED_EVENT_SHORT, documentIngestRequestedPayloadSchema);
106
- r.defineEvent(DOCUMENT_INGEST_SKIPPED_EVENT_SHORT, documentIngestSkippedPayloadSchema);
105
+ // "fileName" can carry a real person's name; the payload has no user-subject
106
+ // field to encrypt it under, so piiFields stays "none" pending #2776.
107
+ r.defineEvent(DOCUMENT_INGEST_REQUESTED_EVENT_SHORT, documentIngestRequestedPayloadSchema, {
108
+ piiFields: "none",
109
+ });
110
+ r.defineEvent(DOCUMENT_INGEST_SKIPPED_EVENT_SHORT, documentIngestSkippedPayloadSchema, {
111
+ piiFields: "none",
112
+ });
107
113
 
108
114
  r.multiStreamProjection({
109
115
  name: "request-ingest",
@@ -61,7 +61,7 @@ export function createFeatureTogglesFeature(
61
61
  // alongside every other write — audit.list picks it up automatically,
62
62
  // no dedicated projection needed. Qualified name after prefixing:
63
63
  // "feature-toggles:event:toggle-set" (see constants.FEATURE_TOGGLE_SET_EVENT_NAME).
64
- r.defineEvent("toggle-set", featureToggleSetSchema);
64
+ r.defineEvent("toggle-set", featureToggleSetSchema, { piiFields: "none" });
65
65
 
66
66
  const handlers = {
67
67
  set: r.writeHandler(createSetWriteHandler(options.getRuntime)),
@@ -109,11 +109,25 @@ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATIO
109
109
  r.requires("tenant-lifecycle");
110
110
 
111
111
  // 5 fine-grained domain-events (Payload-Schemas: events.ts).
112
- r.defineEvent(MAIL_ACCOUNT_CONNECTED_EVENT_SHORT, mailAccountEventPayloadSchema);
113
- r.defineEvent(MAIL_ACCOUNT_UPDATED_EVENT_SHORT, mailAccountEventPayloadSchema);
114
- r.defineEvent(MAIL_ACCOUNT_DISCONNECTED_EVENT_SHORT, mailAccountEventPayloadSchema);
115
- r.defineEvent(INBOUND_MESSAGE_RECEIVED_EVENT_SHORT, inboundMessageEventPayloadSchema);
116
- r.defineEvent(MAIL_THREAD_UPDATED_EVENT_SHORT, mailThreadEventPayloadSchema);
112
+ // piiFields: "none" — address/subject/snippet are already ciphertext under the tenant subject key before append (see events.ts).
113
+ // "displayName" stays plaintext (often the mailbox owner's real name for
114
+ // personal accounts); encrypting it would break the entity-projection read
115
+ // path that carries it as `personal: false` (#2776).
116
+ r.defineEvent(MAIL_ACCOUNT_CONNECTED_EVENT_SHORT, mailAccountEventPayloadSchema, {
117
+ piiFields: "none",
118
+ });
119
+ r.defineEvent(MAIL_ACCOUNT_UPDATED_EVENT_SHORT, mailAccountEventPayloadSchema, {
120
+ piiFields: "none",
121
+ });
122
+ r.defineEvent(MAIL_ACCOUNT_DISCONNECTED_EVENT_SHORT, mailAccountEventPayloadSchema, {
123
+ piiFields: "none",
124
+ });
125
+ r.defineEvent(INBOUND_MESSAGE_RECEIVED_EVENT_SHORT, inboundMessageEventPayloadSchema, {
126
+ piiFields: "none",
127
+ });
128
+ r.defineEvent(MAIL_THREAD_UPDATED_EVENT_SHORT, mailThreadEventPayloadSchema, {
129
+ piiFields: "none",
130
+ });
117
131
 
118
132
  // Inline projections — apply in derselben TX wie der Append.
119
133
  r.projection({
@@ -153,7 +153,7 @@ export function createSecretsFeature(opts: SecretsFeatureOptions = {}): FeatureD
153
153
  // discover the type; secrets-context.get parses payloads against
154
154
  // `secretReadSchema` at write time because the low-level append() path
155
155
  // skips ctx.appendEvent's schema-validation guard.
156
- r.defineEvent("read", secretReadSchema);
156
+ r.defineEvent("read", secretReadSchema, { piiFields: "none" });
157
157
 
158
158
  // Per-tenant handlers (set/delete/list) run in the default tenant-scope,
159
159
  // giving them the automatic ctx.db tenant-filter as extra defense.
@@ -146,7 +146,7 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
146
146
  // sessionRevokedSchema explicitly, same guarantee via a different path.
147
147
  // No projection: the payload IS the read, consumed directly off the
148
148
  // event-store NOTIFY (#1560).
149
- r.defineEvent(SESSION_REVOKED_EVENT_SHORT, sessionRevokedSchema);
149
+ r.defineEvent(SESSION_REVOKED_EVENT_SHORT, sessionRevokedSchema, { piiFields: "none" });
150
150
 
151
151
  const handlers = {
152
152
  revoke: r.writeHandler(revokeWrite),
@@ -60,24 +60,41 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
60
60
  // guard's "tenant-lifecycle mounted" gate exactly.
61
61
  r.bootCheck(({ features }) => validateTenantDataHookCoverage(features));
62
62
 
63
- r.defineEvent(DESTRUCTION_REQUESTED_EVENT_SHORT, destructionRequestedSchema);
64
- r.defineEvent(DESTRUCTION_CANCELLED_EVENT_SHORT, destructionCancelledSchema);
65
- r.defineEvent(TENANT_DESTRUCTION_STARTED_EVENT_SHORT, tenantDestructionStartedSchema);
63
+ r.defineEvent(DESTRUCTION_REQUESTED_EVENT_SHORT, destructionRequestedSchema, {
64
+ piiFields: "none",
65
+ });
66
+ r.defineEvent(DESTRUCTION_CANCELLED_EVENT_SHORT, destructionCancelledSchema, {
67
+ piiFields: "none",
68
+ });
69
+ r.defineEvent(TENANT_DESTRUCTION_STARTED_EVENT_SHORT, tenantDestructionStartedSchema, {
70
+ piiFields: "none",
71
+ });
66
72
  r.defineEvent(
67
73
  TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT,
68
74
  tenantDestructionStageStartedSchema,
75
+ {
76
+ piiFields: "none",
77
+ },
69
78
  );
70
79
  r.defineEvent(
71
80
  TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT,
72
81
  tenantDestructionStageSucceededSchema,
82
+ { piiFields: "none" },
73
83
  );
74
- r.defineEvent(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, tenantDestructionStageFailedSchema);
84
+ r.defineEvent(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, tenantDestructionStageFailedSchema, {
85
+ piiFields: "none",
86
+ });
75
87
  r.defineEvent(
76
88
  TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT,
77
89
  tenantDestructionStageAbandonedSchema,
90
+ { piiFields: "none" },
78
91
  );
79
- r.defineEvent(TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT, tenantDestructionCompletedSchema);
80
- r.defineEvent(TENANT_DESTRUCTION_FAILED_EVENT_SHORT, tenantDestructionFailedSchema);
92
+ r.defineEvent(TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT, tenantDestructionCompletedSchema, {
93
+ piiFields: "none",
94
+ });
95
+ r.defineEvent(TENANT_DESTRUCTION_FAILED_EVENT_SHORT, tenantDestructionFailedSchema, {
96
+ piiFields: "none",
97
+ });
81
98
 
82
99
  r.writeHandler(requestDestructionWrite);
83
100
  r.writeHandler(cancelDestructionWrite);
@@ -0,0 +1,139 @@
1
+ // fw#2593 — the plaintext `read_users_email_unique` index used to be a full
2
+ // unique index regardless of softDelete: a soft-deleted user permanently
3
+ // blocked reuse of its email whenever PII/blind-index wasn't configured
4
+ // (the *_bidx partial index from fw#2464 only helps once a blind-index key
5
+ // is actually configured). buildEntityTable / deriveEntityTableMeta now
6
+ // attach `WHERE "is_deleted" = false` to any explicitly declared unique
7
+ // index on a softDelete entity that doesn't carry an author-provided
8
+ // `where` of its own — this proves the plaintext path directly, with no
9
+ // blind-index key configured at all.
10
+
11
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
12
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
13
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
14
+ import { createSystemUser, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
16
+ import {
17
+ createTestDb,
18
+ type TestDb,
19
+ unsafeCreateEntityTable,
20
+ } from "@cosmicdrift/kumiko-framework/stack";
21
+ import {
22
+ resetBlindIndexKeyForTests,
23
+ resetPiiSubjectKmsForTests,
24
+ } from "@cosmicdrift/kumiko-framework/testing";
25
+ import { userEntity, userTable } from "../schema/user";
26
+
27
+ let testDb: TestDb;
28
+ const executor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
29
+
30
+ beforeAll(async () => {
31
+ testDb = await createTestDb();
32
+ await unsafeCreateEntityTable(testDb.db, userEntity, "user");
33
+ await createEventsTable(testDb.db);
34
+ });
35
+
36
+ afterAll(async () => {
37
+ await testDb.cleanup();
38
+ });
39
+
40
+ beforeEach(async () => {
41
+ await asRawClient(testDb.db).unsafe(
42
+ `TRUNCATE kumiko_events, read_users RESTART IDENTITY CASCADE`,
43
+ );
44
+ // The premise of this file is "nothing configured" — global crypto state
45
+ // from another test file running first in the same process must not leak
46
+ // in and accidentally exercise the bidx path instead of the plaintext one.
47
+ resetPiiSubjectKmsForTests();
48
+ resetBlindIndexKeyForTests();
49
+ });
50
+
51
+ afterEach(() => {
52
+ resetPiiSubjectKmsForTests();
53
+ resetBlindIndexKeyForTests();
54
+ });
55
+
56
+ describe("fw#2593 — email unique constraint on the plaintext column (no blind index configured)", () => {
57
+ test("a soft-deleted user's email is reusable when no blind index is configured", async () => {
58
+ const email = "plaintext-soft-deleted@example.com";
59
+ const tdb = createTenantDb(testDb.db, SYSTEM_TENANT_ID, "system");
60
+ const systemUser = createSystemUser(SYSTEM_TENANT_ID);
61
+
62
+ const created = await executor.create(
63
+ { email, displayName: "Departing Plaintext User" },
64
+ systemUser,
65
+ tdb,
66
+ );
67
+ if (!created.isSuccess) throw new Error("expected create to succeed");
68
+
69
+ const deleted = await executor.delete({ id: created.data.id }, systemUser, tdb);
70
+ if (!deleted.isSuccess) throw new Error("expected soft-delete to succeed");
71
+
72
+ const reCreated = await executor.create(
73
+ { email, displayName: "Plaintext Squatter" },
74
+ systemUser,
75
+ tdb,
76
+ );
77
+ if (!reCreated.isSuccess) {
78
+ throw new Error(
79
+ "expected re-create with the same email to succeed once the original row is soft-deleted",
80
+ );
81
+ }
82
+ expect(reCreated.data.id).not.toBe(created.data.id);
83
+
84
+ // DB-proof: both rows survive under the plaintext email — the
85
+ // soft-deleted original stays untouched, the new live row now also
86
+ // carries the email, since the partial index only enforces uniqueness
87
+ // among non-deleted rows.
88
+ const rows = (await asRawClient(testDb.db).unsafe(
89
+ `SELECT "id", "is_deleted" FROM "read_users" WHERE "email" = $1 ORDER BY "is_deleted"`,
90
+ [email],
91
+ )) as ReadonlyArray<{ id: string; is_deleted: boolean }>;
92
+ expect(rows).toHaveLength(2);
93
+ expect(rows[0]).toEqual({ id: String(reCreated.data.id), is_deleted: false });
94
+ expect(rows[1]).toEqual({ id: String(created.data.id), is_deleted: true });
95
+
96
+ // Proves the plaintext path was actually exercised, not the bidx one:
97
+ // no blind-index key is configured in this file, so email_bidx stays NULL.
98
+ const plaintextRows = (await asRawClient(testDb.db).unsafe(
99
+ `SELECT "email", "email_bidx" FROM "read_users" WHERE "id" = $1::uuid`,
100
+ [reCreated.data.id],
101
+ )) as ReadonlyArray<{ email: string; email_bidx: string | null }>;
102
+ expect(plaintextRows[0]?.email).toBe(email);
103
+ expect(plaintextRows[0]?.email_bidx).toBeNull();
104
+ });
105
+
106
+ test("a second live user with the same email is still rejected", async () => {
107
+ const email = "plaintext-live-collision@example.com";
108
+ const tdb = createTenantDb(testDb.db, SYSTEM_TENANT_ID, "system");
109
+ const systemUser = createSystemUser(SYSTEM_TENANT_ID);
110
+
111
+ const first = await executor.create(
112
+ { email, displayName: "First Plaintext User" },
113
+ systemUser,
114
+ tdb,
115
+ );
116
+ if (!first.isSuccess) throw new Error("expected first create to succeed");
117
+
118
+ const second = await executor.create(
119
+ { email, displayName: "Second Plaintext User" },
120
+ systemUser,
121
+ tdb,
122
+ );
123
+ if (second.isSuccess) {
124
+ throw new Error("expected second create with the same live email to fail");
125
+ }
126
+
127
+ expect(second.error.code).toBe("unique_violation");
128
+ expect(second.error.httpStatus).toBe(409);
129
+ const details = second.error.details as { constraintName?: string };
130
+ expect(details.constraintName).toBe("read_users_email_unique");
131
+
132
+ // DB-proof: only the first row survives under that email.
133
+ const rows = (await asRawClient(testDb.db).unsafe(
134
+ `SELECT count(*)::int AS n FROM "read_users" WHERE "email" = $1`,
135
+ [email],
136
+ )) as ReadonlyArray<{ n: number }>;
137
+ expect(rows[0]?.n).toBe(1);
138
+ });
139
+ });
@@ -66,7 +66,7 @@ export async function seedUser(
66
66
  // Interface braucht den Wrap.
67
67
  const tdb = createTenantDb(db, by.tenantId, "system");
68
68
 
69
- const existing = await fetchOne(db, userTable, { email: options.email });
69
+ const existing = await fetchOne(db, userTable, { email: options.email, isDeleted: false });
70
70
  // @cast-boundary db-row: users.id ist uuid-Spalte (string), fetchOne
71
71
  // liefert die Projection-Row als Record<string, unknown>.
72
72
  if (existing) {
@@ -114,7 +114,7 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
114
114
  test("V3: pii field on a projection-only entity (no r.entity) is still caught", () => {
115
115
  const projectionTable = projectionProbeTable("crm_contact_summary");
116
116
  const bad = defineFeature("crm", (r) => {
117
- const contactCreated = r.defineEvent("contact-created", z.unknown());
117
+ const contactCreated = r.defineEvent("contact-created", z.unknown(), { piiFields: "none" });
118
118
  r.projection({
119
119
  name: "contact-summary",
120
120
  source: "contact",
@@ -171,7 +171,9 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
171
171
  const projectionTable = projectionProbeTable("crm_contact_summary_hooked");
172
172
  const hooked = defineFeature("crm-hooked", (r) => {
173
173
  r.requires("user-data-rights");
174
- const contactCreated = r.defineEvent("contact-hooked-created", z.unknown());
174
+ const contactCreated = r.defineEvent("contact-hooked-created", z.unknown(), {
175
+ piiFields: "none",
176
+ });
175
177
  r.projection({
176
178
  name: "contact-summary",
177
179
  source: "contact",
@@ -15,7 +15,11 @@
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
+ import {
19
+ createEventStoreExecutor,
20
+ createTenantDb,
21
+ entityEventName,
22
+ } from "@cosmicdrift/kumiko-framework/db";
19
23
  import {
20
24
  createEntity,
21
25
  createSystemUser,
@@ -337,6 +341,112 @@ describe("forget pipeline honours the effective tenant model", () => {
337
341
  // Payload-tenant latch must still see 2 historical members.
338
342
  expect(await rowCount()).toBe(1);
339
343
  });
344
+
345
+ test("single-user, sole member removed and re-added → rows still erased (#2608)", async () => {
346
+ await seedScopedRow("dddddddd-dddd-4ddd-8ddd-0000000000c7");
347
+ await seed(stack.db).seedForgetUser(FORGET_USER);
348
+
349
+ // Remove + re-add of the SAME person: removeMemberWrite only drops the
350
+ // projection row, so two `tenant-membership.created` events survive for a
351
+ // tenant that never had a co-member. Counting rows instead of identities
352
+ // latched such a tenant to multi-user forever, and every tenantScopedOnly
353
+ // erase hook skipped on the "co-members keep shared data" grounds.
354
+ const first = await seedTenantMembership(stack.db, {
355
+ userId: FORGET_USER,
356
+ tenantId: TENANT,
357
+ roles: ["Member"],
358
+ });
359
+ const deleteResult = await membershipExecutor.delete(
360
+ { id: first.id },
361
+ createSystemUser(TENANT),
362
+ createTenantDb(stack.db, TENANT, "system"),
363
+ );
364
+ if (!deleteResult.isSuccess) {
365
+ throw new Error(`test setup: member removal failed: ${deleteResult.error.code}`);
366
+ }
367
+ await seedTenantMembership(stack.db, {
368
+ userId: FORGET_USER,
369
+ tenantId: TENANT,
370
+ roles: ["Member"],
371
+ });
372
+
373
+ // Pins the premise: 2 created-events, 1 distinct userId, 1 live member.
374
+ const createdEvents = await asRawClient(stack.db).unsafe(
375
+ `SELECT payload->>'userId' AS user_id FROM kumiko_events
376
+ WHERE aggregate_type = 'tenant-membership' AND type = $1 AND payload->>'tenantId' = $2`,
377
+ [entityEventName("tenant-membership", "created"), TENANT],
378
+ );
379
+ expect(createdEvents.length).toBe(2);
380
+ const liveMemberships = await asRawClient(stack.db).unsafe(
381
+ "SELECT id FROM read_tenant_memberships WHERE tenant_id = $1",
382
+ [TENANT],
383
+ );
384
+ expect(liveMemberships.length).toBe(1);
385
+
386
+ const result = await runForgetCleanup({
387
+ db: stack.db,
388
+ registry: stack.registry,
389
+ now: nowInstant(),
390
+ tenantModel: "single-user",
391
+ });
392
+
393
+ expect(result.errors).toHaveLength(0);
394
+ expect(result.processedUserIds).toContain(FORGET_USER);
395
+ expect(await rowCount()).toBe(0);
396
+ });
397
+
398
+ test("single-user, sole member, but a created-event with unreadable userId → rows preserved", async () => {
399
+ await seedScopedRow("dddddddd-dddd-4ddd-8ddd-0000000000c8");
400
+ await seed(stack.db).seedForgetUser(FORGET_USER);
401
+ await seedTenantMembership(stack.db, {
402
+ userId: FORGET_USER,
403
+ tenantId: TENANT,
404
+ roles: ["Member"],
405
+ });
406
+
407
+ // A created-event whose userId payload cannot be read may name a second
408
+ // member — the fail-safe branch must survive the #2608 fix.
409
+ await asRawClient(stack.db).unsafe(
410
+ `INSERT INTO kumiko_events
411
+ (aggregate_id, aggregate_type, tenant_id, version, type, payload, metadata, created_by)
412
+ VALUES ($1, 'tenant-membership', $2, 1, $3, $4, '{}'::jsonb, 'system')`,
413
+ [
414
+ "eeeeeeee-eeee-4eee-8eee-0000000000c8",
415
+ TENANT,
416
+ entityEventName("tenant-membership", "created"),
417
+ // Object, not JSON.stringify: a string param into a jsonb column is
418
+ // encoded a second time and lands as a JSON scalar, which no
419
+ // payload->>'...' lookup would ever match.
420
+ { tenantId: TENANT },
421
+ ],
422
+ );
423
+
424
+ const createdEvents = await asRawClient(stack.db).unsafe(
425
+ `SELECT payload->>'userId' AS user_id FROM kumiko_events
426
+ WHERE aggregate_type = 'tenant-membership' AND type = $1 AND payload->>'tenantId' = $2`,
427
+ [entityEventName("tenant-membership", "created"), TENANT],
428
+ );
429
+ expect(createdEvents.length).toBe(2);
430
+
431
+ // Without exactly one live member the live gate would answer multi-user
432
+ // before the history latch runs, and this test would prove nothing.
433
+ const liveMemberships = await asRawClient(stack.db).unsafe(
434
+ "SELECT id FROM read_tenant_memberships WHERE tenant_id = $1",
435
+ [TENANT],
436
+ );
437
+ expect(liveMemberships.length).toBe(1);
438
+
439
+ const result = await runForgetCleanup({
440
+ db: stack.db,
441
+ registry: stack.registry,
442
+ now: nowInstant(),
443
+ tenantModel: "single-user",
444
+ });
445
+
446
+ expect(result.errors).toHaveLength(0);
447
+ expect(result.processedUserIds).toContain(FORGET_USER);
448
+ expect(await rowCount()).toBe(1);
449
+ });
340
450
  });
341
451
 
342
452
  describe("run-forget-cleanup job — real glue-code, not a hand-set tenantModel", () => {
@@ -76,7 +76,7 @@ export function createRequestDeletionByEmailHandler(opts: RequestDeletionByEmail
76
76
  const userRow = await fetchOne<{ id: string; status: string; email: string }>(
77
77
  ctx.db.raw,
78
78
  userTable,
79
- { email: event.payload.email },
79
+ { email: event.payload.email, isDeleted: false },
80
80
  );
81
81
  if (!userRow || userRow["status"] !== USER_STATUS.Active || !userRow["email"]) {
82
82
  return success;
@@ -573,19 +573,26 @@ async function resolveEffectiveTenantModel(
573
573
  // rows.
574
574
  //
575
575
  // Filter by payload->>'tenantId' (not the event column tenant_id): cross-
576
- // tenant SystemAdmin adds land under the actor's tenant_id (#2347). SQL
577
- // aggregation stays fail-safe when userId payloads are unreadable: more
578
- // created-rows than distinct userIds ⇒ treat as multi-user.
576
+ // tenant SystemAdmin adds land under the actor's tenant_id (#2347).
577
+ //
578
+ // Counting created-ROWS against distinct userIds latched a tenant to
579
+ // multi-user as soon as its sole member was removed and re-added (#2608) —
580
+ // two created-events, one identity, no co-member ever. The fail-safe only
581
+ // needs to cover payloads whose userId cannot be READ, so the unreadable
582
+ // remainder is measured directly instead of inferred from the row count.
583
+ // NULLIF folds an empty-string userId into that remainder (it names nobody).
579
584
  async function everHadMultipleMembers(db: DbRunner, tenantId: TenantId): Promise<boolean> {
580
585
  const eventType = entityEventName("tenant-membership", "created");
581
586
  const rows = await executeRawQuery<{
582
587
  event_count: number;
588
+ readable_users: number;
583
589
  distinct_users: number;
584
590
  }>(
585
591
  db,
586
592
  `SELECT
587
593
  COUNT(*)::int AS event_count,
588
- COUNT(DISTINCT payload->>'userId')::int AS distinct_users
594
+ COUNT(NULLIF(payload->>'userId', ''))::int AS readable_users,
595
+ COUNT(DISTINCT NULLIF(payload->>'userId', ''))::int AS distinct_users
589
596
  FROM kumiko_events
590
597
  WHERE aggregate_type = 'tenant-membership'
591
598
  AND type = $1
@@ -595,11 +602,9 @@ async function everHadMultipleMembers(db: DbRunner, tenantId: TenantId): Promise
595
602
  const row = rows[0];
596
603
  if (!row) return false;
597
604
  if (row.distinct_users > 1) return true;
598
- // Unreadable / missing userId payloads: fail-safe to multi-user
599
- // (including a lone created-event with no readable userId).
600
- if (row.event_count >= 1 && row.distinct_users < 1) return true;
601
- if (row.event_count > 1 && row.distinct_users <= 1) return true;
602
- return false;
605
+ // Unreadable / missing userId payloads: fail-safe to multi-user, each one
606
+ // could name a second member.
607
+ return row.event_count > row.readable_users;
603
608
  }
604
609
 
605
610
  // Mapping retention.strategy → user-data-rights.UserDataDeleteStrategy.