@cosmicdrift/kumiko-bundled-features 0.24.1 → 0.26.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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/env-schemas.test.ts +53 -11
  3. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +75 -11
  4. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +86 -16
  5. package/src/auth-email-password/handlers/confirm-token-flow.ts +12 -8
  6. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +28 -1
  7. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +139 -0
  8. package/src/custom-fields/__tests__/drift.test.ts +43 -0
  9. package/src/custom-fields/__tests__/field-definition-row.test.ts +62 -0
  10. package/src/custom-fields/__tests__/retention.integration.test.ts +76 -0
  11. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +196 -75
  12. package/src/custom-fields/__tests__/wire-for-entity.test.ts +29 -0
  13. package/src/custom-fields/constants.ts +8 -7
  14. package/src/custom-fields/db/queries/field-access.ts +1 -1
  15. package/src/custom-fields/db/queries/projection.ts +13 -5
  16. package/src/custom-fields/db/queries/quota.ts +1 -1
  17. package/src/custom-fields/db/queries/retention.ts +20 -6
  18. package/src/custom-fields/executor.ts +10 -0
  19. package/src/custom-fields/feature.ts +32 -39
  20. package/src/custom-fields/handlers/clear-custom-field.write.ts +5 -1
  21. package/src/custom-fields/handlers/define-system-field.write.ts +5 -22
  22. package/src/custom-fields/handlers/define-tenant-field.write.ts +13 -23
  23. package/src/custom-fields/handlers/delete-system-field.write.ts +3 -9
  24. package/src/custom-fields/handlers/delete-tenant-field.write.ts +3 -9
  25. package/src/custom-fields/lib/field-access.ts +4 -0
  26. package/src/custom-fields/lib/field-definition-row.ts +33 -0
  27. package/src/custom-fields/run-retention.ts +6 -5
  28. package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +45 -10
  29. package/src/custom-fields/web/client-plugin.tsx +2 -0
  30. package/src/custom-fields/web/custom-fields-form-section.tsx +19 -9
  31. package/src/custom-fields/web/i18n.ts +30 -0
  32. package/src/custom-fields/wire-for-entity.ts +1 -1
  33. package/src/custom-fields/wire-user-data-rights.ts +9 -0
  34. package/src/feature-toggles/handlers/set.write.ts +13 -8
  35. package/src/file-provider-inmemory/__tests__/feature.test.ts +55 -0
  36. package/src/file-provider-s3/__tests__/feature.test.ts +27 -0
  37. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +54 -12
  38. package/src/foundation-shared/__tests__/config-helpers.test.ts +72 -0
  39. package/src/foundation-shared/config-helpers.ts +7 -3
  40. package/src/secrets/feature.ts +4 -11
  41. package/src/subscription-stripe/feature.ts +2 -2
  42. package/src/template-resolver/handlers/list.query.ts +12 -10
  43. package/src/tenant/__tests__/seed-testing.integration.test.ts +26 -0
  44. package/src/tenant/seeding.ts +3 -3
  45. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +55 -0
  46. package/src/tier-engine/feature.ts +8 -2
  47. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +26 -74
  48. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +211 -0
  49. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +272 -0
  50. package/src/user-data-rights/__tests__/forget-test-helpers.ts +86 -0
  51. package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
  52. package/src/user-data-rights/run-forget-cleanup.ts +77 -36
  53. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +21 -6
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { fieldDefinitionAggregateId } from "../aggregate-id";
3
+
4
+ // Drift-Pin-Tests — diese Werte sind Cross-File-Contracts, ein Wechsel muss
5
+ // bewusst geschehen. Wenn diese Tests rot werden: stop, denk nach, revert.
6
+ // aggregate-id.ts verweist namentlich auf diese Datei.
7
+
8
+ describe("custom-fields drift pins", () => {
9
+ test("fieldDefinition aggregate-id namespace is stable across boots", () => {
10
+ // FIELD_DEFINITION_NAMESPACE is in stone — changing it re-keys every
11
+ // existing fieldDefinition-stream and breaks event-replay +
12
+ // definition-history. If this fails: revert the namespace, do not adjust
13
+ // the expected values.
14
+ const sys = fieldDefinitionAggregateId(
15
+ "00000000-0000-0000-0000-000000000001",
16
+ "customer",
17
+ "internalNumber",
18
+ );
19
+ const sysAgain = fieldDefinitionAggregateId(
20
+ "00000000-0000-0000-0000-000000000001",
21
+ "customer",
22
+ "internalNumber",
23
+ );
24
+ const otherTenant = fieldDefinitionAggregateId(
25
+ "11111111-1111-1111-1111-111111111111",
26
+ "customer",
27
+ "internalNumber",
28
+ );
29
+ const otherKey = fieldDefinitionAggregateId(
30
+ "00000000-0000-0000-0000-000000000001",
31
+ "customer",
32
+ "otherKey",
33
+ );
34
+
35
+ expect(sys).toBe(sysAgain); // deterministic: same triple → same id
36
+ expect(sys).not.toBe(otherTenant); // tenantId is part of the key (scope isolation)
37
+ expect(sys).not.toBe(otherKey); // fieldKey is part of the key
38
+ // Pinned actual outputs — the drift-detector for the namespace constant.
39
+ expect(sys).toBe("a6e22096-55ac-54c1-a759-aa42fa94dbe8");
40
+ expect(otherTenant).toBe("5a6cbaf1-159e-53a1-aaed-0e3b836decbe");
41
+ expect(otherKey).toBe("4b683fa3-9560-5747-bee9-46ea237393ac");
42
+ });
43
+ });
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildFieldDefinitionColumns } from "../lib/field-definition-row";
3
+ import { defineFieldPayloadSchema } from "../schemas";
4
+
5
+ function parse(input: unknown) {
6
+ const result = defineFieldPayloadSchema.safeParse(input);
7
+ if (!result.success) {
8
+ throw new Error(`payload invalid: ${result.error.message}`);
9
+ }
10
+ return result.data;
11
+ }
12
+
13
+ describe("buildFieldDefinitionColumns — denormalized columns derive from serializedField", () => {
14
+ test("serializedField.required wins when present (no top-level required)", () => {
15
+ const payload = parse({
16
+ entityName: "customer",
17
+ fieldKey: "internalNumber",
18
+ serializedField: { type: "text", required: true, maxLength: 50 },
19
+ });
20
+ const row = buildFieldDefinitionColumns(payload);
21
+ expect(row.required).toBe(true);
22
+ expect(JSON.parse(row.serializedField).required).toBe(true);
23
+ });
24
+
25
+ test("top-level value is used when serializedField omits the key", () => {
26
+ const payload = parse({
27
+ entityName: "customer",
28
+ fieldKey: "vipFlag",
29
+ serializedField: { type: "boolean" },
30
+ required: true,
31
+ searchable: true,
32
+ displayOrder: 3,
33
+ });
34
+ const row = buildFieldDefinitionColumns(payload);
35
+ expect(row.required).toBe(true);
36
+ expect(row.searchable).toBe(true);
37
+ expect(row.displayOrder).toBe(3);
38
+ });
39
+
40
+ test("serializedField wins over a conflicting top-level value", () => {
41
+ const payload = parse({
42
+ entityName: "customer",
43
+ fieldKey: "code",
44
+ serializedField: { type: "text", required: false },
45
+ required: true,
46
+ });
47
+ const row = buildFieldDefinitionColumns(payload);
48
+ expect(row.required).toBe(false);
49
+ });
50
+
51
+ test("defaults to false/0 when neither source sets the key", () => {
52
+ const payload = parse({
53
+ entityName: "customer",
54
+ fieldKey: "note",
55
+ serializedField: { type: "text" },
56
+ });
57
+ const row = buildFieldDefinitionColumns(payload);
58
+ expect(row.required).toBe(false);
59
+ expect(row.searchable).toBe(false);
60
+ expect(row.displayOrder).toBe(0);
61
+ });
62
+ });
@@ -28,6 +28,7 @@ import {
28
28
  } from "@cosmicdrift/kumiko-framework/stack";
29
29
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
30
30
  import { z } from "zod";
31
+ import { applyRetentionRemovals, selectHostRowsWithCustomFields } from "../db/queries/retention";
31
32
  import { fieldDefinitionEntity } from "../entity";
32
33
  import { createCustomFieldsFeature } from "../feature";
33
34
  import { runCustomFieldsRetention } from "../run-retention";
@@ -261,4 +262,79 @@ describe("T1.5d: per-field retention sweep", () => {
261
262
  expect(cf).not.toHaveProperty("temp");
262
263
  expect(cf["keepThis"]).toBe("should-stay");
263
264
  });
265
+
266
+ test("mixed strategies on one row: delete drops the key, anonymize nulls it, others stay", async () => {
267
+ const propertyId = "66666666-6666-4000-8000-000000000006";
268
+ await defineField("dropMe", {
269
+ type: "text",
270
+ retention: { keepFor: "30d", strategy: "delete" },
271
+ });
272
+ await defineField("nullMe", {
273
+ type: "text",
274
+ retention: { keepFor: "30d", strategy: "anonymize" },
275
+ });
276
+ await defineField("keepMe", { type: "text" });
277
+ await createProperty(propertyId, "MixedStrategies");
278
+ await setField(propertyId, "dropMe", "secret-a");
279
+ await setField(propertyId, "nullMe", "secret-b");
280
+ await setField(propertyId, "keepMe", "public-c");
281
+ await stack.eventDispatcher?.runOnce();
282
+
283
+ await backdateRow(propertyId, "2026-04-22T10:00:00Z");
284
+
285
+ const report = await runCustomFieldsRetention({
286
+ db: stack.db,
287
+ tenantId: admin.tenantId,
288
+ entityName: "property",
289
+ entityTable: propertyTable,
290
+ now: NOW,
291
+ });
292
+
293
+ expect(report.removalsByFieldKey).toEqual({ dropMe: 1, nullMe: 1 });
294
+ const cf = (await readRow(propertyId))?.["custom_fields"] as Record<string, unknown>;
295
+ expect(cf).not.toHaveProperty("dropMe");
296
+ expect(cf).toHaveProperty("nullMe");
297
+ expect(cf["nullMe"]).toBeNull();
298
+ expect(cf["keepMe"]).toBe("public-c");
299
+ });
300
+
301
+ test("atomic removal touches only the targeted keys — a value written after the scan is not clobbered", async () => {
302
+ const propertyId = "77777777-7777-4000-8000-000000000007";
303
+ await defineField("temp", {
304
+ type: "text",
305
+ retention: { keepFor: "30d", strategy: "delete" },
306
+ });
307
+ // No retention policy → never swept; stands in for a concurrent edit.
308
+ await defineField("liveEdit", { type: "text" });
309
+ await createProperty(propertyId, "Concurrent");
310
+ await setField(propertyId, "temp", "expired-value");
311
+ await stack.eventDispatcher?.runOnce();
312
+ await backdateRow(propertyId, "2026-04-22T10:00:00Z");
313
+
314
+ // The sweep scans the row (snapshot has only `temp`)...
315
+ const snapshot = await selectHostRowsWithCustomFields(
316
+ stack.db,
317
+ "read_t15d_properties",
318
+ admin.tenantId,
319
+ );
320
+ expect(snapshot).toHaveLength(1);
321
+
322
+ // ...then a concurrent set-custom-field adds `liveEdit`...
323
+ await setField(propertyId, "liveEdit", "written-mid-sweep");
324
+ await stack.eventDispatcher?.runOnce();
325
+
326
+ // ...then the sweep applies the removal it computed from the (now stale)
327
+ // snapshot. This drives `applyRetentionRemovals` directly because the
328
+ // scan→write window inside `runCustomFieldsRetention` can't be paused
329
+ // mid-flight in-process. It pins the property that actually removes the
330
+ // lost-update class: the write is `custom_fields - {temp}` against the LIVE
331
+ // row, never a read-modify-write of the whole jsonb — so a key absent from
332
+ // the removal lists survives. The pre-fix code rebuilt the whole object
333
+ // from the stale snapshot and would have dropped `liveEdit`.
334
+ await applyRetentionRemovals(stack.db, "read_t15d_properties", ["temp"], [], propertyId);
335
+
336
+ const cf = (await readRow(propertyId))?.["custom_fields"] as Record<string, unknown>;
337
+ expect(cf).not.toHaveProperty("temp");
338
+ expect(cf["liveEdit"]).toBe("written-mid-sweep");
339
+ });
264
340
  });
@@ -1,22 +1,33 @@
1
- // T1.5c — user-data-rights wiring for custom-fields.
1
+ // T1.5c — user-data-rights wiring for custom-fields, exercised through the
2
+ // REAL export/forget runners (runUserExport / runForgetCleanup), not by
3
+ // calling the registered hooks in isolation.
2
4
  //
3
- // Verifies the full DSGVO loop for custom-field values on a user-owned
4
- // host entity:
5
+ // What the real runners prove that direct hook calls cannot:
5
6
  //
6
- // * Export (Art. 15+20): every row owned by the user contributes its
7
- // customFields jsonb into the user's export bundle under
8
- // `<entity>.customFields`.
7
+ // * runUserExport actually picks up the custom-fields export hook from the
8
+ // registry and folds its snippet into the user's cross-tenant bundle.
9
9
  //
10
- // * Forget strategy=anonymize (Art. 17 with retention obligation):
11
- // sensitive customFields keys are stripped from the jsonb; non-
12
- // sensitive keys stay so co-tenants / co-authors keep useful data.
10
+ // * runForgetCleanup fires BOTH the host EXT_USER_DATA hook (owner-nulling
11
+ // anonymize) AND the custom-fields strip hook, in the order their declared
12
+ // `order` demands. The strip declares order -100 so it redacts sensitive
13
+ // jsonb keyed on `inserted_by_id` BEFORE the host hook nulls that column.
14
+ // If the ordering regressed, the host hook would null inserted_by_id first,
15
+ // the strip's `WHERE inserted_by_id = userId` would match 0 rows, and
16
+ // sensitive PII would silently survive (Art. 17 violation). Calling the
17
+ // hooks by hand never exercised this interaction.
13
18
  //
14
- // * Forget strategy=delete: no-op the host entity's own user-data-
15
- // rights hook handles the row delete, jsonb travels with the row.
19
+ // * The anonymize-vs-delete strategy comes from the data-retention policy:
20
+ // no override strategy "delete" (custom-fields strip is a no-op, host
21
+ // deletes the row); per-tenant anonymize override → strategy "anonymize"
22
+ // (strip runs, host nulls the owner).
16
23
 
17
24
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
18
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
19
- import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
25
+ import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
26
+ import {
27
+ buildEntityTable,
28
+ createEventStoreExecutor,
29
+ createTenantDb,
30
+ } from "@cosmicdrift/kumiko-framework/db";
20
31
  import {
21
32
  createEntity,
22
33
  createEntityExecutor,
@@ -32,19 +43,28 @@ import {
32
43
  resetEventStore,
33
44
  setupTestStack,
34
45
  type TestStack,
46
+ TestUsers,
35
47
  unsafeCreateEntityTable,
36
48
  } from "@cosmicdrift/kumiko-framework/stack";
49
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
37
50
  import { z } from "zod";
38
51
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
39
- import { createDataRetentionFeature } from "../../data-retention";
52
+ import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
53
+ import { tenantRetentionOverrideTable } from "../../data-retention/schema/tenant-retention-override";
40
54
  import { createSessionsFeature } from "../../sessions";
41
- import { createUserFeature, userEntity } from "../../user";
55
+ import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
42
56
  import { createUserDataRightsFeature } from "../../user-data-rights";
57
+ import { runForgetCleanup } from "../../user-data-rights/run-forget-cleanup";
58
+ import { runUserExport } from "../../user-data-rights/run-user-export";
43
59
  import { fieldDefinitionEntity } from "../entity";
44
60
  import { createCustomFieldsFeature } from "../feature";
45
61
  import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
46
62
  import { wireCustomFieldsUserDataRightsFor } from "../wire-user-data-rights";
47
63
 
64
+ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
65
+ const NOW = (): Instant => getTemporal().Now.instant();
66
+ const PAST = (): Instant => getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
67
+
48
68
  const propertyEntity = createEntity({
49
69
  table: "read_t15c_properties",
50
70
  fields: {
@@ -55,15 +75,12 @@ const propertyEntity = createEntity({
55
75
  const propertyTable = buildEntityTable("property", propertyEntity);
56
76
 
57
77
  // Host entity gets its own EXT_USER_DATA-registration too — that's the
58
- // canonical setup (host bundle handles row-anonymize/delete, custom-fields
59
- // adds its strip-sensitive-jsonb layer on top). Both hooks fire in the
60
- // same cleanup-run.
78
+ // canonical setup. The host's anonymize hook NULLS inserted_by_id (default
79
+ // order 0); the custom-fields strip (order -100) must run first. Both fire in
80
+ // the same runForgetCleanup sub-transaction.
61
81
  const hostExportHook: UserDataExportHook = async (ctx) => {
62
82
  const rows = await asRawClient(ctx.db).unsafe(
63
- `
64
- SELECT id, name FROM read_t15c_properties
65
- WHERE inserted_by_id = $1 AND tenant_id = $2
66
- `,
83
+ `SELECT id, name FROM read_t15c_properties WHERE inserted_by_id = $1 AND tenant_id = $2`,
67
84
  [ctx.userId, ctx.tenantId],
68
85
  );
69
86
  const list = rows as ReadonlyArray<Record<string, unknown>>;
@@ -77,19 +94,15 @@ const hostExportHook: UserDataExportHook = async (ctx) => {
77
94
  const hostDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
78
95
  if (strategy === "delete") {
79
96
  await asRawClient(ctx.db).unsafe(
80
- `
81
- DELETE FROM read_t15c_properties
82
- WHERE inserted_by_id = $1 AND tenant_id = $2
83
- `,
97
+ `DELETE FROM read_t15c_properties WHERE inserted_by_id = $1 AND tenant_id = $2`,
84
98
  [ctx.userId, ctx.tenantId],
85
99
  );
86
100
  } else {
87
- // anonymize: clear owner, keep row + non-sensitive customFields
101
+ // anonymize: clear owner, keep row + non-sensitive customFields. Runs AFTER
102
+ // the custom-fields strip (order -100 < 0) — if it ran first, the strip's
103
+ // owner-keyed WHERE would match nothing.
88
104
  await asRawClient(ctx.db).unsafe(
89
- `
90
- UPDATE read_t15c_properties SET inserted_by_id = NULL
91
- WHERE inserted_by_id = $1 AND tenant_id = $2
92
- `,
105
+ `UPDATE read_t15c_properties SET inserted_by_id = NULL WHERE inserted_by_id = $1 AND tenant_id = $2`,
93
106
  [ctx.userId, ctx.tenantId],
94
107
  );
95
108
  }
@@ -125,8 +138,10 @@ const propertyFeature = defineFeature("property-t15c", (r) => {
125
138
 
126
139
  const customFieldsFeature = createCustomFieldsFeature();
127
140
  const admin = createTestUser({ id: 1, roles: ["TenantAdmin"] });
141
+ const TENANT = admin.tenantId;
128
142
 
129
143
  let stack: TestStack;
144
+ let overrideExecutor: ReturnType<typeof createEventStoreExecutor>;
130
145
 
131
146
  beforeAll(async () => {
132
147
  stack = await setupTestStack({
@@ -143,7 +158,34 @@ beforeAll(async () => {
143
158
  await unsafeCreateEntityTable(stack.db, userEntity);
144
159
  await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
145
160
  await unsafeCreateEntityTable(stack.db, propertyEntity);
161
+ await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
146
162
  await createEventsTable(stack.db);
163
+
164
+ // runForgetCleanup + runUserExport iterate the user's memberships. Provide a
165
+ // minimal membership read-model (same shape the user-data-rights suite uses).
166
+ await asRawClient(stack.db).unsafe(`
167
+ CREATE TABLE IF NOT EXISTS read_tenant_memberships (
168
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
169
+ tenant_id UUID NOT NULL,
170
+ user_id TEXT NOT NULL,
171
+ version INTEGER NOT NULL DEFAULT 0,
172
+ inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
173
+ modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
174
+ inserted_by_id TEXT,
175
+ modified_by_id TEXT,
176
+ is_deleted BOOLEAN NOT NULL DEFAULT false,
177
+ deleted_at TIMESTAMPTZ,
178
+ deleted_by_id TEXT,
179
+ roles TEXT NOT NULL DEFAULT '[]',
180
+ UNIQUE(user_id, tenant_id)
181
+ )
182
+ `);
183
+
184
+ overrideExecutor = createEventStoreExecutor(
185
+ tenantRetentionOverrideTable,
186
+ tenantRetentionOverrideEntity,
187
+ { entityName: "tenant-retention-override" },
188
+ );
147
189
  });
148
190
 
149
191
  afterAll(async () => {
@@ -154,6 +196,9 @@ beforeEach(async () => {
154
196
  await resetEventStore(stack);
155
197
  await asRawClient(stack.db).unsafe(`DELETE FROM read_t15c_properties`);
156
198
  await asRawClient(stack.db).unsafe(`DELETE FROM read_custom_field_definitions`);
199
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
200
+ await asRawClient(stack.db).unsafe(`DELETE FROM read_tenant_memberships`);
201
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantRetentionOverrideTable.tableName}"`);
157
202
  });
158
203
 
159
204
  async function defineField(fieldKey: string, serializedField: Record<string, unknown>) {
@@ -185,43 +230,78 @@ async function setField(entityId: string, fieldKey: string, value: unknown) {
185
230
 
186
231
  async function readRow(id: string): Promise<Record<string, unknown> | undefined> {
187
232
  const rows = await asRawClient(stack.db).unsafe(
188
- `SELECT id, custom_fields FROM read_t15c_properties WHERE id = $1`,
233
+ `SELECT id, custom_fields, inserted_by_id FROM read_t15c_properties WHERE id = $1`,
189
234
  [id],
190
235
  );
191
236
  const list = rows as ReadonlyArray<Record<string, unknown>>;
192
237
  return list[0];
193
238
  }
194
239
 
195
- async function callExportHook(userId: string, tenantId: string) {
196
- const usages = stack.registry.getExtensionUsages(EXT_USER_DATA);
197
- const customFieldsUsage = usages.find(
198
- (u) =>
199
- u.entityName === "property" &&
200
- (u.options as { export?: unknown })?.export &&
201
- u.options !== undefined &&
202
- (u.options as Record<string, unknown>)["export"] !== hostExportHook,
240
+ // Seed the acting admin as a normal active user with a membership in TENANT so
241
+ // the export runner iterates their data. Status active = NOT picked up by
242
+ // runForgetCleanup.
243
+ async function seedActiveUserWithMembership(): Promise<void> {
244
+ await insertOne(stack.db, userTable, {
245
+ id: admin.id,
246
+ tenantId: TENANT,
247
+ email: `admin@example.com`,
248
+ passwordHash: "hashed",
249
+ displayName: "Admin",
250
+ locale: "de",
251
+ emailVerified: true,
252
+ roles: '["TenantAdmin"]',
253
+ status: USER_STATUS.Active,
254
+ });
255
+ await seedMembership();
256
+ }
257
+
258
+ // Seed the acting admin as DeletionRequested + grace expired so
259
+ // runForgetCleanup picks them up, with a membership in TENANT.
260
+ async function seedForgetUserWithMembership(): Promise<void> {
261
+ await insertOne(stack.db, userTable, {
262
+ id: admin.id,
263
+ tenantId: TENANT,
264
+ email: `admin@example.com`,
265
+ passwordHash: "hashed",
266
+ displayName: "Admin",
267
+ locale: "de",
268
+ emailVerified: true,
269
+ roles: '["TenantAdmin"]',
270
+ status: USER_STATUS.DeletionRequested,
271
+ gracePeriodEnd: PAST(),
272
+ });
273
+ await seedMembership();
274
+ }
275
+
276
+ async function seedMembership(): Promise<void> {
277
+ await asRawClient(stack.db).unsafe(
278
+ `INSERT INTO read_tenant_memberships (tenant_id, user_id, roles)
279
+ VALUES ($1, $2, '["TenantAdmin"]') ON CONFLICT (user_id, tenant_id) DO NOTHING`,
280
+ [TENANT, admin.id],
203
281
  );
204
- if (!customFieldsUsage) throw new Error("custom-fields user-data-rights export hook not found");
205
- const hook = (customFieldsUsage.options as { export: UserDataExportHook }).export;
206
- return hook({ db: stack.db, tenantId, userId });
207
282
  }
208
283
 
209
- async function callDeleteHook(userId: string, tenantId: string, strategy: "anonymize" | "delete") {
210
- const usages = stack.registry.getExtensionUsages(EXT_USER_DATA);
211
- const customFieldsUsage = usages.find(
212
- (u) =>
213
- u.entityName === "property" &&
214
- (u.options as { delete?: unknown })?.delete &&
215
- u.options !== undefined &&
216
- (u.options as Record<string, unknown>)["delete"] !== hostDeleteHook,
284
+ // Set a per-tenant retention override for the property entity through the same
285
+ // event-store path the forget resolver reads — no test-only shortcut.
286
+ async function seedPropertyAnonymizeOverride(): Promise<void> {
287
+ const by = { ...TestUsers.systemAdmin, tenantId: TENANT };
288
+ const result = await overrideExecutor.create(
289
+ {
290
+ entityName: "property",
291
+ config: JSON.stringify({ keepFor: "30d", strategy: "anonymize" }),
292
+ reason: "test",
293
+ tenantId: TENANT,
294
+ },
295
+ by,
296
+ createTenantDb(stack.db, TENANT, "system"),
217
297
  );
218
- if (!customFieldsUsage) throw new Error("custom-fields user-data-rights delete hook not found");
219
- const hook = (customFieldsUsage.options as { delete: UserDataDeleteHook }).delete;
220
- return hook({ db: stack.db, tenantId, userId }, strategy);
298
+ if (!result.isSuccess)
299
+ throw new Error(`seedPropertyAnonymizeOverride failed: ${JSON.stringify(result)}`);
221
300
  }
222
301
 
223
- describe("T1.5c: user-data-rights wiring for custom-fields", () => {
224
- test("export: customFields jsonb travels into the user's export snippet", async () => {
302
+ describe("T1.5c: custom-fields user-data-rights through the real runners", () => {
303
+ test("export: customFields jsonb lands in the user's export bundle", async () => {
304
+ await seedActiveUserWithMembership();
225
305
  const propertyId = "11111111-1111-4000-8000-000000000001";
226
306
  await defineField("email", { type: "text", sensitive: true });
227
307
  await defineField("vipFlag", { type: "boolean" });
@@ -230,17 +310,27 @@ describe("T1.5c: user-data-rights wiring for custom-fields", () => {
230
310
  await setField(propertyId, "vipFlag", true);
231
311
  await stack.eventDispatcher?.runOnce();
232
312
 
233
- const snippet = await callExportHook(String(admin.id), admin.tenantId);
234
- expect(snippet).not.toBeNull();
235
- expect(snippet?.entity).toBe("property.customFields");
236
- expect(snippet?.rows).toHaveLength(1);
237
- expect(snippet?.rows[0]?.["customFields"]).toMatchObject({
313
+ const bundle = await runUserExport({
314
+ db: stack.db,
315
+ registry: stack.registry,
316
+ userId: admin.id,
317
+ now: NOW(),
318
+ });
319
+
320
+ const tenantSection = bundle.tenants.find((t) => t.tenantId === TENANT);
321
+ expect(tenantSection).toBeDefined();
322
+ const cfSnippet = tenantSection?.entities.find((e) => e.entity === "property.customFields");
323
+ expect(cfSnippet).toBeDefined();
324
+ expect(cfSnippet?.rows).toHaveLength(1);
325
+ expect(cfSnippet?.rows[0]?.["customFields"]).toMatchObject({
238
326
  email: "alice@example.com",
239
327
  vipFlag: true,
240
328
  });
241
329
  });
242
330
 
243
- test("forget anonymize: sensitive keys stripped, non-sensitive keys kept", async () => {
331
+ test("forget anonymize: strip runs BEFORE host owner-nulling → sensitive key gone, non-sensitive kept", async () => {
332
+ await seedForgetUserWithMembership();
333
+ await seedPropertyAnonymizeOverride();
244
334
  const propertyId = "22222222-2222-4000-8000-000000000002";
245
335
  await defineField("email", { type: "text", sensitive: true });
246
336
  await defineField("vipFlag", { type: "boolean" });
@@ -249,50 +339,81 @@ describe("T1.5c: user-data-rights wiring for custom-fields", () => {
249
339
  await setField(propertyId, "vipFlag", true);
250
340
  await stack.eventDispatcher?.runOnce();
251
341
 
252
- await callDeleteHook(String(admin.id), admin.tenantId, "anonymize");
342
+ const result = await runForgetCleanup({
343
+ db: stack.db,
344
+ registry: stack.registry,
345
+ now: NOW(),
346
+ });
347
+ expect(result.processedUserIds).toContain(admin.id);
348
+ expect(result.errors).toHaveLength(0);
253
349
 
254
350
  const row = await readRow(propertyId);
351
+ // Host hook ran (owner nulled), and the strip ran BEFORE it (sensitive
352
+ // key removed despite the owner-keyed WHERE — proof of the -100 ordering).
353
+ expect(row?.["inserted_by_id"]).toBeNull();
255
354
  const customFields = row?.["custom_fields"] as Record<string, unknown> | undefined;
256
355
  expect(customFields).toBeDefined();
257
356
  expect(customFields).not.toHaveProperty("email");
258
357
  expect(customFields).toMatchObject({ vipFlag: true });
259
358
  });
260
359
 
261
- test("forget delete: no-op on customFields (host hook removes the row)", async () => {
360
+ test("forget delete (no override strategy delete): host removes the row, strip is a no-op", async () => {
361
+ await seedForgetUserWithMembership();
362
+ // No retention override → policyToStrategy(null) = "delete".
262
363
  const propertyId = "33333333-3333-4000-8000-000000000003";
263
364
  await defineField("email", { type: "text", sensitive: true });
264
365
  await createProperty(propertyId, "Delete-Me");
265
366
  await setField(propertyId, "email", "alice@example.com");
266
367
  await stack.eventDispatcher?.runOnce();
267
368
 
268
- // call only the custom-fields delete hook (strategy=delete) — verify
269
- // it doesn't mutate the row (the host hook would handle the actual
270
- // row delete; we're proving custom-fields stays out of the way).
271
- await callDeleteHook(String(admin.id), admin.tenantId, "delete");
369
+ const result = await runForgetCleanup({
370
+ db: stack.db,
371
+ registry: stack.registry,
372
+ now: NOW(),
373
+ });
374
+ expect(result.processedUserIds).toContain(admin.id);
272
375
 
273
- const row = await readRow(propertyId);
274
- const customFields = row?.["custom_fields"] as Record<string, unknown> | undefined;
275
- expect(customFields).toMatchObject({ email: "alice@example.com" });
376
+ // Host delete-hook removed the row; custom-fields strip stayed out of the
377
+ // way (it returns early on strategy=delete).
378
+ expect(await readRow(propertyId)).toBeUndefined();
276
379
  });
277
380
 
278
381
  test("export: rows without customFields are not included in the snippet", async () => {
382
+ await seedActiveUserWithMembership();
279
383
  const propertyId = "44444444-4444-4000-8000-000000000004";
280
384
  await createProperty(propertyId, "NoCustomFields");
385
+ await stack.eventDispatcher?.runOnce();
281
386
 
282
- const snippet = await callExportHook(String(admin.id), admin.tenantId);
283
- expect(snippet).toBeNull();
387
+ const bundle = await runUserExport({
388
+ db: stack.db,
389
+ registry: stack.registry,
390
+ userId: admin.id,
391
+ now: NOW(),
392
+ });
393
+
394
+ const tenantSection = bundle.tenants.find((t) => t.tenantId === TENANT);
395
+ const cfSnippet = tenantSection?.entities.find((e) => e.entity === "property.customFields");
396
+ expect(cfSnippet).toBeUndefined();
284
397
  });
285
398
 
286
- test("anonymize without sensitive fields defined is a no-op (everything kept)", async () => {
399
+ test("forget anonymize without sensitive fields defined all customFields kept", async () => {
400
+ await seedForgetUserWithMembership();
401
+ await seedPropertyAnonymizeOverride();
287
402
  const propertyId = "55555555-5555-4000-8000-000000000005";
288
403
  await defineField("nonSensitive", { type: "text" });
289
404
  await createProperty(propertyId, "AllStay");
290
405
  await setField(propertyId, "nonSensitive", "still-here");
291
406
  await stack.eventDispatcher?.runOnce();
292
407
 
293
- await callDeleteHook(String(admin.id), admin.tenantId, "anonymize");
408
+ const result = await runForgetCleanup({
409
+ db: stack.db,
410
+ registry: stack.registry,
411
+ now: NOW(),
412
+ });
413
+ expect(result.processedUserIds).toContain(admin.id);
294
414
 
295
415
  const row = await readRow(propertyId);
416
+ expect(row?.["inserted_by_id"]).toBeNull();
296
417
  expect((row?.["custom_fields"] as Record<string, unknown>)?.["nonSensitive"]).toBe(
297
418
  "still-here",
298
419
  );
@@ -76,6 +76,35 @@ describe("wireCustomFieldsFor", () => {
76
76
  });
77
77
  });
78
78
 
79
+ test("postQuery-hook lets base columns win over shadowing custom fieldKeys", async () => {
80
+ const feature = defineFeature("test-property", (r) => {
81
+ r.entity("property", propertyEntity);
82
+ wireCustomFieldsFor(r, "property", propertyTable);
83
+ });
84
+
85
+ const hook = feature.entityHooks.postQuery["property"]?.[0]?.fn;
86
+ const result = await hook?.(
87
+ {
88
+ entityName: "property",
89
+ rows: [
90
+ {
91
+ id: "p1",
92
+ name: "Hofgarten",
93
+ // a malicious/colliding custom fieldKey must not shadow the real column
94
+ customFields: { id: "spoofed", name: "spoofed", internalNumber: "X-42" },
95
+ },
96
+ ],
97
+ },
98
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
99
+ {} as never,
100
+ );
101
+ expect(result?.rows[0]).toMatchObject({
102
+ id: "p1",
103
+ name: "Hofgarten",
104
+ internalNumber: "X-42",
105
+ });
106
+ });
107
+
79
108
  test("postQuery-hook handles missing/invalid customFields gracefully", async () => {
80
109
  const feature = defineFeature("test-property", (r) => {
81
110
  r.entity("property", propertyEntity);