@cosmicdrift/kumiko-bundled-features 0.313.0 → 0.315.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.
Files changed (38) hide show
  1. package/package.json +9 -9
  2. package/src/auth-email-password/changes.json +6 -0
  3. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +66 -0
  4. package/src/auth-email-password/web/signup-complete-screen.tsx +10 -4
  5. package/src/compliance-profiles/resolve-for-tenant.ts +15 -5
  6. package/src/data-retention/__tests__/retention-cleanup-files.integration.test.ts +6 -6
  7. package/src/data-retention/__tests__/retention-cleanup-kms.integration.test.ts +1 -1
  8. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +10 -10
  9. package/src/data-retention/changes.json +6 -0
  10. package/src/data-retention/feature.ts +1 -1
  11. package/src/data-retention/handlers/policy-for.query.ts +14 -31
  12. package/src/data-retention/index.ts +2 -0
  13. package/src/data-retention/resolve-for-tenant.ts +25 -17
  14. package/src/data-retention/resolve-tenant-preset.ts +5 -2
  15. package/src/data-retention/run-retention-cleanup.ts +5 -4
  16. package/src/delivery/__tests__/delivery.integration.test.ts +189 -19
  17. package/src/delivery/address-opt-out.ts +49 -21
  18. package/src/delivery/changes.json +12 -0
  19. package/src/delivery/constants.ts +1 -0
  20. package/src/delivery/delivery-service.ts +48 -27
  21. package/src/delivery/feature.ts +5 -1
  22. package/src/delivery/handlers/unsubscribe-address.write.ts +40 -0
  23. package/src/delivery/handlers/unsubscribe-user.write.ts +43 -0
  24. package/src/delivery/index.ts +1 -0
  25. package/src/delivery/public-names.ts +6 -0
  26. package/src/delivery/unsubscribe.ts +144 -67
  27. package/src/delivery/upsert-preference.ts +74 -60
  28. package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +1 -0
  29. package/src/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +2 -1
  30. package/src/file-derivatives/__tests__/public-variant-predicate-args.integration.test.ts +2 -1
  31. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +4 -1
  32. package/src/shared/index.ts +1 -0
  33. package/src/shared/is-tenant-db.ts +9 -0
  34. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +5 -1
  35. package/src/user-data-rights/__tests__/retention-preset-forget.integration.test.ts +272 -0
  36. package/src/user-data-rights/run-forget-cleanup.ts +6 -1
  37. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +9 -7
  38. package/src/workflow-runner/__tests__/workflow-runner.integration.test.ts +7 -2
@@ -0,0 +1,272 @@
1
+ // Retention-preset-driven forget + policy-for (kumiko-framework#3283, Option B).
2
+ //
3
+ // Before this fix, forget (run-forget-cleanup.ts) and policy-for
4
+ // (policy-for.query.ts) resolved retention WITHOUT the tenant's
5
+ // compliance-profile-derived preset (Layer 2 of the 3-layer resolver): an
6
+ // entity with no own `retention` default (like `invoice`) fell straight to
7
+ // `source: "none"` -> policyToStrategy(null) -> "delete", hard-deleting data
8
+ // a mapped compliance profile (e.g. `de-hr-dsgvo-hgb`) requires to survive as
9
+ // blockDelete/anonymize. This proves the real end-to-end chain:
10
+ // compliance-profiles:write:set-profile -> resolveProfileForTenant ->
11
+ // resolveTenantRetentionPreset -> resolveRetentionPolicyForTenant ->
12
+ // (forget) policyToStrategy / (policy-for query) EffectiveRetentionPolicy.source
13
+ //
14
+ // A test-only `invoice` entity stands in for the real dsgvo-hgb preset entry
15
+ // (RETENTION_PRESETS["dsgvo-hgb"].invoice = blockDelete/10y) since no bundled
16
+ // feature ships a real invoice entity.
17
+
18
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
19
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
20
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
21
+ import {
22
+ buildEntityTable,
23
+ createEventStoreExecutor,
24
+ createTenantDb,
25
+ type TenantDb,
26
+ } from "@cosmicdrift/kumiko-framework/db";
27
+ import {
28
+ access,
29
+ createEntity,
30
+ createSystemUser,
31
+ createTextField,
32
+ defineFeature,
33
+ EXT_USER_DATA,
34
+ type FeatureDefinition,
35
+ type UserDataDeleteHook,
36
+ } from "@cosmicdrift/kumiko-framework/engine";
37
+ import {
38
+ createTestUser,
39
+ setupTestStack,
40
+ type TestStack,
41
+ unsafeCreateEntityTable,
42
+ } from "@cosmicdrift/kumiko-framework/stack";
43
+ import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
44
+ import * as z from "zod";
45
+ import {
46
+ ComplianceProfileHandlers,
47
+ createComplianceProfilesFeature,
48
+ tenantComplianceProfileEntity,
49
+ tenantComplianceProfileTable,
50
+ } from "../../compliance-profiles";
51
+ import {
52
+ createDataRetentionFeature,
53
+ resolveRetentionPolicyForTenant,
54
+ tenantRetentionOverrideEntity,
55
+ tenantRetentionOverrideTable,
56
+ } from "../../data-retention";
57
+ import { createSessionsFeature } from "../../sessions";
58
+ import { createUserFeature, userEntity, userTable } from "../../user";
59
+ import { createUserDataRightsFeature } from "../feature";
60
+ import { runForgetCleanup } from "../run-forget-cleanup";
61
+ import {
62
+ createForgetSeeders,
63
+ nowInstant,
64
+ READ_TENANT_MEMBERSHIPS_DDL,
65
+ } from "./forget-test-helpers";
66
+
67
+ const POLICY_FOR = "data-retention:query:policy-for";
68
+ const CREATE_INVOICE = "test-invoice:write:create";
69
+ const INVOICE_TABLE = "read_test_retention_invoices";
70
+
71
+ // No own `retention` default -> Layer 1 is empty, so whether forget/policy-for
72
+ // see the preset (Layer 2) is exactly what's under test.
73
+ const invoiceEntity = createEntity({
74
+ table: INVOICE_TABLE,
75
+ fields: {
76
+ amount: createTextField({ required: true, personal: false, reason: "technical_reference" }),
77
+ },
78
+ });
79
+ const invoiceTable = buildEntityTable("invoice", invoiceEntity);
80
+ const invoiceCrud = createEventStoreExecutor(invoiceTable, invoiceEntity, {
81
+ entityName: "invoice",
82
+ });
83
+
84
+ // Mirrors fileRefDeleteHook's executor-based pattern (forget writes go
85
+ // through events, not deleteMany/updateMany, so a projection rebuild replays
86
+ // the erasure): hard-delete on "delete", sever the owner-link on "anonymize".
87
+ const invoiceDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
88
+ const systemUser = createSystemUser(ctx.tenantId);
89
+ const rows = await ctx.db.selectMany<{ id: string }>(invoiceTable, {
90
+ tenantId: ctx.tenantId,
91
+ insertedById: ctx.userId,
92
+ });
93
+ for (const row of rows) {
94
+ if (strategy === "delete") {
95
+ await invoiceCrud.forget({ id: row.id }, systemUser, ctx.db);
96
+ } else {
97
+ await invoiceCrud.update(
98
+ { id: row.id, changes: { insertedById: null } },
99
+ systemUser,
100
+ ctx.db,
101
+ {
102
+ skipOptimisticLock: true,
103
+ },
104
+ );
105
+ }
106
+ }
107
+ };
108
+
109
+ const testInvoiceFeature: FeatureDefinition = defineFeature("test-invoice", (r) => {
110
+ r.entity("invoice", invoiceEntity);
111
+ const handlers = {
112
+ create: r.writeHandler({
113
+ name: "create",
114
+ schema: z.object({ amount: z.string() }),
115
+ access: { roles: access.authenticated },
116
+ description: "test-only invoice creation for the retention-preset forget test",
117
+ handler: async (event, ctx) =>
118
+ invoiceCrud.create({ amount: event.payload.amount }, event.user, ctx.db),
119
+ }),
120
+ };
121
+ r.useExtension(EXT_USER_DATA, "invoice", {
122
+ export: async () => null,
123
+ delete: invoiceDeleteHook,
124
+ });
125
+ return { handlers };
126
+ });
127
+
128
+ const TENANT_A = "00000000-0000-4000-8000-0000000000e1";
129
+ const TENANT_B = "00000000-0000-4000-8000-0000000000e2";
130
+ const FORGET_USER = "cccccccc-cccc-4ccc-8ccc-0000000000e1";
131
+
132
+ let stack: TestStack;
133
+ // biome-ignore lint/suspicious/noExplicitAny: dummy writer; this fixture has no binaries.
134
+ const seed = (db: unknown) => createForgetSeeders(db as any, { write: async () => {} });
135
+
136
+ function tenantAdmin(id: string, tenantId: string) {
137
+ return createTestUser({ id, tenantId, roles: ["TenantAdmin"] });
138
+ }
139
+
140
+ async function fetchInvoice(
141
+ id: string,
142
+ ): Promise<{ id: string; inserted_by_id: string | null } | null> {
143
+ const result = await asRawClient(stack.db).unsafe(
144
+ `SELECT id, inserted_by_id FROM ${INVOICE_TABLE} WHERE id = $1`,
145
+ [id],
146
+ );
147
+ // biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
148
+ const rows = ((result as any).rows ?? result) as Array<{
149
+ id: string;
150
+ inserted_by_id: string | null;
151
+ }>;
152
+ return rows[0] ?? null;
153
+ }
154
+
155
+ beforeAll(async () => {
156
+ stack = await setupTestStack({
157
+ features: [
158
+ createUserFeature(),
159
+ authFoundationFeature,
160
+ createSessionsFeature(),
161
+ createDataRetentionFeature(),
162
+ createComplianceProfilesFeature(),
163
+ createUserDataRightsFeature(),
164
+ testInvoiceFeature,
165
+ ],
166
+ });
167
+ await unsafeCreateEntityTable(stack.db, userEntity);
168
+ await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
169
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
170
+ await unsafeCreateEntityTable(stack.db, invoiceEntity, "invoice");
171
+ await asRawClient(stack.db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
172
+ });
173
+
174
+ afterAll(async () => {
175
+ await stack.cleanup();
176
+ });
177
+
178
+ beforeEach(async () => {
179
+ await resetTestTables(stack.db, [
180
+ userTable,
181
+ "read_tenant_memberships",
182
+ invoiceTable,
183
+ tenantRetentionOverrideTable,
184
+ tenantComplianceProfileTable,
185
+ ]);
186
+ });
187
+
188
+ describe("data-retention preset honored by forget + policy-for (kumiko-framework#3283)", () => {
189
+ test("tenant with a mapped compliance profile -> invoice forget anonymizes (blockDelete preset); tenant without one -> hard-deleted", async () => {
190
+ await seed(stack.db).seedForgetUser(FORGET_USER);
191
+ await seed(stack.db).seedMembership(FORGET_USER, TENANT_A);
192
+ await seed(stack.db).seedMembership(FORGET_USER, TENANT_B);
193
+
194
+ const adminA = tenantAdmin("11111111-0000-4000-8000-0000000000e1", TENANT_A);
195
+ await stack.http.writeOk(
196
+ ComplianceProfileHandlers.setProfile,
197
+ { profileKey: "de-hr-dsgvo-hgb" },
198
+ adminA,
199
+ );
200
+ // Tenant B: no profile set -> control, unchanged hard-delete behavior.
201
+
202
+ const invoiceUserA = { id: FORGET_USER, tenantId: TENANT_A, roles: ["User"] };
203
+ const invoiceUserB = { id: FORGET_USER, tenantId: TENANT_B, roles: ["User"] };
204
+ const invoiceA = await stack.http.writeOk<{ id: string }>(
205
+ CREATE_INVOICE,
206
+ { amount: "100.00" },
207
+ invoiceUserA,
208
+ );
209
+ const invoiceB = await stack.http.writeOk<{ id: string }>(
210
+ CREATE_INVOICE,
211
+ { amount: "200.00" },
212
+ invoiceUserB,
213
+ );
214
+
215
+ const result = await runForgetCleanup({
216
+ db: stack.db,
217
+ registry: stack.registry,
218
+ now: nowInstant(),
219
+ });
220
+ expect(result.errors).toHaveLength(0);
221
+ expect(result.processedUserIds).toContain(FORGET_USER);
222
+
223
+ const rowA = await fetchInvoice(invoiceA.id);
224
+ expect(rowA).not.toBeNull();
225
+ expect(rowA?.inserted_by_id).toBeNull();
226
+
227
+ expect(await fetchInvoice(invoiceB.id)).toBeNull();
228
+ });
229
+
230
+ test("policy-for query reflects the same preset layer per tenant", async () => {
231
+ const adminA = tenantAdmin("11111111-0000-4000-8000-0000000000e2", TENANT_A);
232
+ const adminB = tenantAdmin("11111111-0000-4000-8000-0000000000e3", TENANT_B);
233
+ await stack.http.writeOk(
234
+ ComplianceProfileHandlers.setProfile,
235
+ { profileKey: "de-hr-dsgvo-hgb" },
236
+ adminA,
237
+ );
238
+
239
+ const resultA = await stack.http.queryOk<{
240
+ source: string;
241
+ policy: { strategy: string } | null;
242
+ }>(POLICY_FOR, { entityName: "invoice" }, adminA);
243
+ expect(resultA.source).toBe("preset");
244
+ expect(resultA.policy?.strategy).toBe("blockDelete");
245
+
246
+ const resultB = await stack.http.queryOk<{ source: string }>(
247
+ POLICY_FOR,
248
+ { entityName: "invoice" },
249
+ adminB,
250
+ );
251
+ expect(resultB.source).not.toBe("preset");
252
+ });
253
+
254
+ test("resolveRetentionPolicyForTenant resolves the preset through a TenantDb, not just a bulk DbRunner", async () => {
255
+ const adminC = tenantAdmin("11111111-0000-4000-8000-0000000000e4", TENANT_A);
256
+ await stack.http.writeOk(
257
+ ComplianceProfileHandlers.setProfile,
258
+ { profileKey: "de-hr-dsgvo-hgb" },
259
+ adminC,
260
+ );
261
+
262
+ const tenantDb: TenantDb = createTenantDb(stack.db, TENANT_A, "tenant");
263
+ const policy = await resolveRetentionPolicyForTenant({
264
+ db: tenantDb,
265
+ registry: stack.registry,
266
+ tenantId: TENANT_A,
267
+ entityName: "invoice",
268
+ });
269
+ expect(policy.source).toBe("preset");
270
+ expect(policy.policy?.strategy).toBe("blockDelete");
271
+ });
272
+ });
@@ -63,7 +63,7 @@ import {
63
63
  type SearchAdapter,
64
64
  } from "@cosmicdrift/kumiko-framework/search";
65
65
  import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
66
- import { resolveRetentionPolicyForTenant } from "../data-retention";
66
+ import { resolveRetentionPolicyForTenant, resolveTenantRetentionPreset } from "../data-retention";
67
67
  import { decryptStoredPii, runInSubTransaction } from "../shared";
68
68
  import { tenantMembershipsTable } from "../tenant";
69
69
  import { USER_STATUS, userTable } from "../user";
@@ -430,6 +430,10 @@ async function processUser(args: {
430
430
  // tenant truly has one member, so a stray invite can't let a per-user
431
431
  // forget erase a co-member's tenant-scoped data (money-path safety).
432
432
  const tenantModel = await resolveEffectiveTenantModel(tx, tenantId, appTenantModel);
433
+ // Resolved ONCE per tenant (not per hookEntry below) — the compliance-
434
+ // profile lookup it does internally is a single indexed read; N entities
435
+ // re-deriving it per tenant would be N reads for the same answer.
436
+ const tenantPreset = await resolveTenantRetentionPreset({ db: tx, registry, tenantId });
433
437
  for (const entry of hookEntries) {
434
438
  currentEntityName = entry.entityName;
435
439
  const policy = await resolveRetentionPolicyForTenant({
@@ -437,6 +441,7 @@ async function processUser(args: {
437
441
  registry,
438
442
  tenantId,
439
443
  entityName: entry.entityName,
444
+ preloadedTenantPreset: tenantPreset,
440
445
  });
441
446
  const strategy = policyToStrategy(policy.policy?.strategy ?? null);
442
447
 
@@ -38,8 +38,9 @@ const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName
38
38
  // not the subject axis.
39
39
  // "anonymize": insertedById=null, row + binary survive (FK refs can still
40
40
  // point at it; person-link removed) — applies to ALL rows
41
- // when the entity strategy itself is already anonymize (e.g.
42
- // blockDelete retention), regardless of the field.
41
+ // when a retention override on fileRef (anonymize or
42
+ // blockDelete, e.g. a statutory retention duty) sets the
43
+ // strategy, regardless of the field.
43
44
  //
44
45
  // **Provider-Resolution:** der Provider kommt zur Lauf-Zeit aus
45
46
  // `ctx.buildStorageProvider(ctx.tenantId)` — der Forget-Orchestrator
@@ -216,11 +217,12 @@ export const fileRefDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
216
217
  });
217
218
 
218
219
  if (strategy !== "delete") {
219
- // anonymize: insertedById=null, FileRef + binary bleiben. Use-case: shared
220
- // chat-Attachment im Multi-User-Channel — Author-ID raus, Datei bleibt sichtbar.
221
- // Applies to ALL rows — the entity strategy comes from a retention
222
- // policy (e.g. blockDelete) and overrides the per-field decision below,
223
- // which only applies for strategy="delete".
220
+ // Only reachable via an explicit retention override on fileRef:
221
+ // policyToStrategy maps anonymize and blockDelete to "anonymize", typically
222
+ // for a statutory retention duty (Art. 17(3)(b) GDPR, HGB/AO). Row and
223
+ // binary must survive, so only the uploader link is severed. Applies to
224
+ // ALL rows because the override replaces the per-field decision below,
225
+ // which only runs for strategy="delete".
224
226
  await severPersonLink(ctx.db, systemUser, rows);
225
227
  // skip: anonymize is complete — the hard-delete path below runs only for strategy "delete".
226
228
  return;
@@ -23,7 +23,12 @@ import {
23
23
  } from "@cosmicdrift/kumiko-framework/engine";
24
24
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
25
25
  import { getConsumerState } from "@cosmicdrift/kumiko-framework/pipeline";
26
- import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
26
+ import {
27
+ drainEventConsumers,
28
+ setupTestStack,
29
+ type TestStack,
30
+ TestUsers,
31
+ } from "@cosmicdrift/kumiko-framework/stack";
27
32
  import { workflowRunAggregateId } from "../aggregate-id";
28
33
  import { registerEventTrigger } from "../event-trigger";
29
34
  import { workflowRunnerFeature } from "../feature";
@@ -190,7 +195,7 @@ describe("workflow-runner event-trigger", () => {
190
195
  // Consumers run their turns concurrently, so events an earlier test's
191
196
  // workflow emitted may still be ahead of this consumer's cursor; drain
192
197
  // them first so `processed` below counts only this test's trigger.
193
- while (((await stack.eventDispatcher?.runOnce())?.processed ?? 0) > 0) {}
198
+ await drainEventConsumers(stack, [consumerName]);
194
199
 
195
200
  await insertOne(stack.db, eventsTable, {
196
201
  aggregateId: crypto.randomUUID(),