@cosmicdrift/kumiko-bundled-features 0.249.0 → 0.250.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 +9 -9
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +69 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -0
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +3 -0
- package/src/billing-foundation/feature.ts +14 -5
- package/src/cap-counter/feature.ts +1 -1
- package/src/crypto-shredding/feature.ts +4 -2
- package/src/custom-fields/feature.ts +9 -2
- package/src/document-ingest-foundation/feature.ts +8 -2
- package/src/feature-toggles/feature.ts +1 -1
- package/src/inbound-mail-foundation/feature.ts +19 -5
- package/src/secrets/feature.ts +1 -1
- package/src/sessions/feature.ts +1 -1
- package/src/tenant-lifecycle/feature.ts +23 -6
- package/src/user/__tests__/email-unique-plaintext-soft-delete.integration.test.ts +139 -0
- package/src/user/seeding.ts +1 -1
- package/src/user-data-rights/__tests__/boot-checks.test.ts +4 -2
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +111 -1
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +1 -1
- package/src/user-data-rights/run-forget-cleanup.ts +14 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.250.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.
|
|
134
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
133
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.250.0",
|
|
134
|
+
"@cosmicdrift/kumiko-framework": "0.250.0",
|
|
135
|
+
"@cosmicdrift/kumiko-headless": "0.250.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer": "0.250.0",
|
|
137
|
+
"@cosmicdrift/kumiko-renderer-web": "0.250.0",
|
|
138
|
+
"@cosmicdrift/kumiko-types": "0.250.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.
|
|
168
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
167
|
+
"@cosmicdrift/kumiko-locale-de": "0.250.0",
|
|
168
|
+
"@cosmicdrift/kumiko-locale-es": "0.250.0"
|
|
169
169
|
}
|
|
170
170
|
}
|
package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts
CHANGED
|
@@ -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
|
-
|
|
90
|
-
r.defineEvent(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
r.defineEvent(
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
r.defineEvent(
|
|
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({
|
package/src/secrets/feature.ts
CHANGED
|
@@ -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.
|
package/src/sessions/feature.ts
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/user/seeding.ts
CHANGED
|
@@ -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 {
|
|
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).
|
|
577
|
-
//
|
|
578
|
-
// created-
|
|
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(
|
|
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
|
-
//
|
|
600
|
-
|
|
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.
|