@cosmicdrift/kumiko-bundled-features 0.24.0 → 0.25.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__/auth.integration.test.ts +37 -0
  4. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +32 -0
  5. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +31 -0
  6. package/src/auth-email-password/handlers/change-password.write.ts +12 -2
  7. package/src/auth-email-password/handlers/confirm-token-flow.ts +17 -2
  8. package/src/compliance-profiles/__tests__/parse-override.test.ts +53 -0
  9. package/src/compliance-profiles/_internal/parse-override.ts +8 -7
  10. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +28 -1
  11. package/src/custom-fields/__tests__/cross-tenant-set-write.integration.test.ts +178 -0
  12. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +139 -0
  13. package/src/custom-fields/__tests__/drift.test.ts +43 -0
  14. package/src/custom-fields/__tests__/field-access.integration.test.ts +59 -0
  15. package/src/custom-fields/__tests__/field-definition-row.test.ts +62 -0
  16. package/src/custom-fields/__tests__/retention.integration.test.ts +76 -0
  17. package/src/custom-fields/__tests__/value-schema.test.ts +54 -0
  18. package/src/custom-fields/__tests__/wire-for-entity.test.ts +29 -0
  19. package/src/custom-fields/constants.ts +8 -7
  20. package/src/custom-fields/db/queries/projection.ts +19 -7
  21. package/src/custom-fields/db/queries/retention.ts +20 -6
  22. package/src/custom-fields/executor.ts +10 -0
  23. package/src/custom-fields/feature.ts +32 -39
  24. package/src/custom-fields/handlers/clear-custom-field.write.ts +8 -1
  25. package/src/custom-fields/handlers/define-system-field.write.ts +5 -22
  26. package/src/custom-fields/handlers/define-tenant-field.write.ts +13 -23
  27. package/src/custom-fields/handlers/delete-system-field.write.ts +3 -9
  28. package/src/custom-fields/handlers/delete-tenant-field.write.ts +3 -9
  29. package/src/custom-fields/handlers/set-custom-field.write.ts +8 -1
  30. package/src/custom-fields/lib/field-access.ts +9 -4
  31. package/src/custom-fields/lib/field-definition-row.ts +33 -0
  32. package/src/custom-fields/lib/value-schema.ts +14 -2
  33. package/src/custom-fields/run-retention.ts +6 -5
  34. package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +45 -10
  35. package/src/custom-fields/web/client-plugin.tsx +2 -0
  36. package/src/custom-fields/web/custom-fields-form-section.tsx +19 -9
  37. package/src/custom-fields/web/i18n.ts +30 -0
  38. package/src/custom-fields/wire-for-entity.ts +9 -2
  39. package/src/custom-fields/wire-user-data-rights.ts +9 -0
  40. package/src/feature-toggles/handlers/set.write.ts +13 -8
  41. package/src/secrets/feature.ts +4 -11
  42. package/src/subscription-stripe/feature.ts +2 -2
  43. package/src/template-resolver/handlers/list.query.ts +12 -10
  44. package/src/tenant/__tests__/seed-testing.integration.test.ts +26 -0
  45. package/src/tenant/seeding.ts +3 -3
  46. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +11 -11
  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
@@ -19,6 +19,7 @@ import {
19
19
  createTextField,
20
20
  defineEntityListHandler,
21
21
  defineFeature,
22
+ SYSTEM_TENANT_ID,
22
23
  } from "@cosmicdrift/kumiko-framework/engine";
23
24
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
24
25
  import {
@@ -102,6 +103,15 @@ beforeEach(async () => {
102
103
  // (Memory: feedback_role_naming_drift — bundled-features-Convention vs.
103
104
  // platform-Convention). Wir bauen einen tenant-admin für die Tests.
104
105
  const admin = createTestUser({ roles: ["TenantAdmin"] });
106
+ const systemAdmin = createTestUser({ roles: ["SystemAdmin"] });
107
+
108
+ async function countDefinitions(tenantId: string, fieldKey: string): Promise<number> {
109
+ const rows = await asRawClient(stack.db).unsafe(
110
+ "SELECT count(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1 AND field_key = $2",
111
+ [tenantId, fieldKey],
112
+ );
113
+ return (rows as ReadonlyArray<{ n: number }>)[0]?.n ?? 0;
114
+ }
105
115
 
106
116
  async function defineField(entityName: string, fieldKey: string, type = "text") {
107
117
  return stack.http.writeOk(
@@ -260,6 +270,84 @@ describe("custom-fields integration — Last-Wins on concurrent set", () => {
260
270
  });
261
271
  });
262
272
 
273
+ describe("custom-fields integration — define/delete handler coverage (B1)", () => {
274
+ // feature.test.ts only covers schema/aggregate-id/registration shape. These
275
+ // drive the handler bodies through the real dispatcher: the deterministic
276
+ // aggregate-id → version_conflict on a duplicate define, the system-tenant
277
+ // guard on define-tenant-field, and the system-scope define→delete roundtrip.
278
+
279
+ test("re-defining the same tenant-field → 409 (deterministic aggregate-id conflict)", async () => {
280
+ await defineField("property", "color", "text");
281
+ const err = await stack.http.writeErr(
282
+ "custom-fields:write:define-tenant-field",
283
+ {
284
+ entityName: "property",
285
+ fieldKey: "color",
286
+ serializedField: { type: "text" },
287
+ required: false,
288
+ searchable: false,
289
+ displayOrder: 0,
290
+ },
291
+ admin,
292
+ );
293
+ expect(err.httpStatus).toBe(409);
294
+ // Only the first define produced a row.
295
+ expect(await countDefinitions(admin.tenantId, "color")).toBe(1);
296
+ });
297
+
298
+ test("define-tenant-field rejects a caller whose tenant IS the system tenant", async () => {
299
+ // The strict guard (isSystemTenant) blocks system-scope writes through the
300
+ // tenant handler — system definitions must go via define-system-field.
301
+ const systemScopedAdmin = createTestUser({
302
+ roles: ["TenantAdmin"],
303
+ tenantId: SYSTEM_TENANT_ID,
304
+ });
305
+ const err = await stack.http.writeErr(
306
+ "custom-fields:write:define-tenant-field",
307
+ {
308
+ entityName: "property",
309
+ fieldKey: "leaky",
310
+ serializedField: { type: "text" },
311
+ required: false,
312
+ searchable: false,
313
+ displayOrder: 0,
314
+ },
315
+ systemScopedAdmin,
316
+ );
317
+ // The guard throws a plain Error → 500 internal_error. Pin the guard's own
318
+ // message (surfaced as the InternalError cause in test/dev) so this can't
319
+ // be satisfied by some unrelated 5xx that also happens to write no row.
320
+ expect(err.httpStatus).toBe(500);
321
+ expect(err.code).toBe("internal_error");
322
+ const causeMessage = (err.details as { causeMessage?: string } | undefined)?.causeMessage ?? "";
323
+ expect(causeMessage).toContain("define-system-field");
324
+ expect(await countDefinitions(SYSTEM_TENANT_ID, "leaky")).toBe(0);
325
+ });
326
+
327
+ test("define-system-field → delete-system-field roundtrip (SystemAdmin, system scope)", async () => {
328
+ const defineRes = await stack.http.writeOk(
329
+ "custom-fields:write:define-system-field",
330
+ {
331
+ entityName: "property",
332
+ fieldKey: "vendorTag",
333
+ serializedField: { type: "text" },
334
+ required: false,
335
+ searchable: false,
336
+ displayOrder: 0,
337
+ },
338
+ systemAdmin,
339
+ );
340
+ expect(defineRes).toBeDefined();
341
+ expect(await countDefinitions(SYSTEM_TENANT_ID, "vendorTag")).toBe(1);
342
+ await stack.http.writeOk(
343
+ "custom-fields:write:delete-system-field",
344
+ { entityName: "property", fieldKey: "vendorTag" },
345
+ systemAdmin,
346
+ );
347
+ expect(await countDefinitions(SYSTEM_TENANT_ID, "vendorTag")).toBe(0);
348
+ });
349
+ });
350
+
263
351
  describe("custom-fields integration — value validation (Builder-Reuse)", () => {
264
352
  async function setErr(entityId: string, fieldKey: string, value: unknown) {
265
353
  return stack.http.writeErr(
@@ -393,6 +481,57 @@ describe("custom-fields integration — value validation (Builder-Reuse)", () =>
393
481
  expect(await rawCustomFields(id)).toMatchObject({ score: 7 });
394
482
  });
395
483
 
484
+ async function setMissingValueErr(entityId: string, fieldKey: string) {
485
+ // value omitted entirely — JSON drops undefined, so the payload arrives
486
+ // without `value` and the schema-level refine rejects it.
487
+ return stack.http.writeErr(
488
+ "custom-fields:write:set-custom-field",
489
+ { entityName: "property", entityId, fieldKey },
490
+ admin,
491
+ );
492
+ }
493
+
494
+ test("missing value → 400 validation_error, no event (set requires a value)", async () => {
495
+ // The payload refine (set-custom-field.write.ts) rejects a missing value
496
+ // before the handler runs — otherwise `undefined` would bind as a jsonb
497
+ // NULL against the NOT-NULL custom_fields column. clear-custom-field is the
498
+ // documented way to remove a value.
499
+ const id = "11111111-2222-4000-8000-00000000000e";
500
+ await defineField("property", "label", "text");
501
+ await createProperty(id, "MissingValue");
502
+
503
+ const err = await setMissingValueErr(id, "label");
504
+ expect(err.httpStatus).toBe(400);
505
+ expect(err.code).toBe("validation_error");
506
+ expect(err.details).toMatchObject({ fields: [{ path: "value" }] });
507
+ expect(await countSetEvents(id)).toBe(0);
508
+ });
509
+
510
+ test("default-having field: a missing value is still rejected (default not silently applied)", async () => {
511
+ // Pre-fix bug: `z.number().default(0).safeParse(undefined)` succeeded with
512
+ // data=0, and the handler emitted `payload.value` (= undefined). The refine
513
+ // now rejects the missing value outright — no event, no defaulted-undefined.
514
+ const id = "22222222-3333-4000-8000-00000000000f";
515
+ await stack.http.writeOk(
516
+ "custom-fields:write:define-tenant-field",
517
+ {
518
+ entityName: "property",
519
+ fieldKey: "rank",
520
+ serializedField: { type: "number", default: 0 },
521
+ required: false,
522
+ searchable: false,
523
+ displayOrder: 0,
524
+ },
525
+ admin,
526
+ );
527
+ await createProperty(id, "DefaultMissing");
528
+
529
+ const err = await setMissingValueErr(id, "rank");
530
+ expect(err.httpStatus).toBe(400);
531
+ expect(err.code).toBe("validation_error");
532
+ expect(await countSetEvents(id)).toBe(0);
533
+ });
534
+
396
535
  test("embedded field rejects a non-object value → 422, no event", async () => {
397
536
  const id = "aaaaaaaa-aaaa-4000-8000-00000000000a";
398
537
  // embedded carries a sub-field schema in serializedField — exercises
@@ -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
+ });
@@ -266,3 +266,62 @@ describe("T1.5b: per-field fieldAccess.write rejects users without required role
266
266
  expect(err.code).toBe("not_found");
267
267
  });
268
268
  });
269
+
270
+ // A row whose serialized_field is corrupt (DB corruption / partial write)
271
+ // must NOT silently drop the per-field write gate. Before the fix the access
272
+ // check fell open ({ ok: true }) and the write went through unvalidated; now
273
+ // it fails closed with a distinct reason.
274
+ describe("fail-closed on corrupt serialized_field", () => {
275
+ async function corruptStoredDefinition(fieldKey: string) {
276
+ await asRawClient(stack.db).unsafe(
277
+ `UPDATE read_custom_field_definitions SET serialized_field = '{not valid json' WHERE field_key = $1`,
278
+ [fieldKey],
279
+ );
280
+ }
281
+
282
+ test("set: corrupt definition row → unprocessable field_definition_corrupt (not silent allow)", async () => {
283
+ const propertyId = "77777777-7777-4000-8000-000000000007";
284
+ await defineField("corruptField", { type: "text", fieldAccess: { write: ["TenantAdmin"] } });
285
+ await corruptStoredDefinition("corruptField");
286
+ await stack.http.writeOk(
287
+ "property-t15b:write:property:create",
288
+ { id: propertyId, name: "Corrupt" },
289
+ tenantAdmin,
290
+ );
291
+
292
+ const err = await stack.http.writeErr(
293
+ "custom-fields:write:set-custom-field",
294
+ { entityName: "property", entityId: propertyId, fieldKey: "corruptField", value: "X-42" },
295
+ tenantAdmin,
296
+ );
297
+
298
+ expect(err.code).toBe("unprocessable");
299
+ expect(err.details).toMatchObject({
300
+ reason: "field_definition_corrupt",
301
+ fieldKey: "corruptField",
302
+ });
303
+ });
304
+
305
+ test("clear: corrupt definition row → unprocessable field_definition_corrupt", async () => {
306
+ const propertyId = "88888888-8888-4000-8000-000000000008";
307
+ await defineField("corruptField", { type: "boolean" });
308
+ await corruptStoredDefinition("corruptField");
309
+ await stack.http.writeOk(
310
+ "property-t15b:write:property:create",
311
+ { id: propertyId, name: "Corrupt2" },
312
+ tenantAdmin,
313
+ );
314
+
315
+ const err = await stack.http.writeErr(
316
+ "custom-fields:write:clear-custom-field",
317
+ { entityName: "property", entityId: propertyId, fieldKey: "corruptField" },
318
+ tenantAdmin,
319
+ );
320
+
321
+ expect(err.code).toBe("unprocessable");
322
+ expect(err.details).toMatchObject({
323
+ reason: "field_definition_corrupt",
324
+ fieldKey: "corruptField",
325
+ });
326
+ });
327
+ });
@@ -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
  });
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildCustomFieldValueSchema } from "../lib/value-schema";
3
+
4
+ describe("buildCustomFieldValueSchema — type-shape only", () => {
5
+ test("strips top-level constraint keys (required/maxLength/format)", () => {
6
+ const schema = buildCustomFieldValueSchema({
7
+ type: "text",
8
+ required: true,
9
+ maxLength: 5,
10
+ format: "email",
11
+ });
12
+ expect(schema).not.toBeNull();
13
+ expect(schema?.safeParse("").success).toBe(true);
14
+ expect(schema?.safeParse("not-an-email-and-way-too-long").success).toBe(true);
15
+ expect(schema?.safeParse(42).success).toBe(false);
16
+ });
17
+
18
+ test("embedded validates sub-field TYPE only — sub-field `required` is stripped", () => {
19
+ const schema = buildCustomFieldValueSchema({
20
+ type: "embedded",
21
+ schema: { city: { type: "text", required: true } },
22
+ });
23
+ expect(schema).not.toBeNull();
24
+ // type-valid objects pass, regardless of the sub-field `required` flag
25
+ expect(schema?.safeParse({ city: "Bonn" }).success).toBe(true);
26
+ expect(schema?.safeParse({}).success).toBe(true);
27
+ expect(schema?.safeParse({ city: "" }).success).toBe(true);
28
+ // type-mismatches still rejected: non-object, and wrong sub-field type
29
+ expect(schema?.safeParse("not-an-object").success).toBe(false);
30
+ expect(schema?.safeParse({ city: 123 }).success).toBe(false);
31
+ });
32
+
33
+ test("embedded strip applies to non-text sub-types too (number)", () => {
34
+ const schema = buildCustomFieldValueSchema({
35
+ type: "embedded",
36
+ schema: { age: { type: "number", required: true } },
37
+ });
38
+ expect(schema).not.toBeNull();
39
+ expect(schema?.safeParse({ age: 7 }).success).toBe(true);
40
+ // required stripped → missing key passes (pre-fix the non-optional number
41
+ // sub-field rejected it)
42
+ expect(schema?.safeParse({}).success).toBe(true);
43
+ // type-mismatch still rejected
44
+ expect(schema?.safeParse({ age: "not-a-number" }).success).toBe(false);
45
+ });
46
+
47
+ test("embedded with an unsupported sub-type → null (skip validation)", () => {
48
+ const schema = buildCustomFieldValueSchema({
49
+ type: "embedded",
50
+ schema: { blob: { type: "json" } },
51
+ });
52
+ expect(schema).toBeNull();
53
+ });
54
+ });
@@ -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);
@@ -28,13 +28,14 @@ export const CustomFieldsQueries = {
28
28
  // `component: { react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME } }`.
29
29
  export const CUSTOM_FIELDS_FORM_EXTENSION_NAME = "CustomFieldsFormSection";
30
30
 
31
- // Event-Type-Names (qualified at registration via r.defineEvent — final
32
- // names are `custom-fields:event:field-definition-created` etc.).
33
- // Short-names MUST be in kebab-case (no dots): qualifyEntityName runs toKebab
34
- // which collapses dots to dashes, so a dotted short-name diverges from the
35
- // registry key when handlers hand-build the qualified string.
36
- export const FIELD_DEFINITION_CREATED_EVENT = "field-definition-created";
37
- export const FIELD_DEFINITION_UPDATED_EVENT = "field-definition-updated";
31
+ // Entity-CRUD auto-events for the `field-definition` entity. registry.ts emits
32
+ // these as `${entityName}.created`/`.updated` (dot form) — they do NOT run
33
+ // through r.defineEvent/toKebab, so the dot MUST stay.
34
+ export const FIELD_DEFINITION_CREATED_EVENT = "field-definition.created";
35
+ export const FIELD_DEFINITION_UPDATED_EVENT = "field-definition.updated";
36
+ // Qualified at registration via r.defineEvent — final name is
37
+ // `custom-fields:event:field-definition-deleted`. Short-name MUST be kebab
38
+ // (no dots): qualifyEntityName runs toKebab which collapses dots to dashes.
38
39
  export const FIELD_DEFINITION_DELETED_EVENT = "field-definition-deleted";
39
40
 
40
41
  // Custom-field-VALUE events. Live auf host-aggregate stream (ES-Option-B).
@@ -1,31 +1,42 @@
1
1
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
4
 
4
5
  function quoteTable(tableName: string): string {
5
6
  return `"${tableName.replace(/"/g, '""')}"`;
6
7
  }
7
8
 
8
9
  function bindJsonbParam(value: unknown): { sql: string; bound: unknown } {
9
- // postgres-js infers boolean params as boolean[] candidatesroute via text::jsonb.
10
- if (typeof value === "boolean") {
10
+ // Scalar JSON primitives can't bind directly to ::jsonb Postgres rejects a
11
+ // bound boolean/number with "cannot cast type boolean/integer to jsonb" (and
12
+ // Bun.SQL infers boolean[] candidates). Route them through ::text::jsonb with
13
+ // a JSON-encoded literal. Objects/arrays/strings already bind as ::jsonb.
14
+ if (typeof value === "boolean" || typeof value === "number") {
11
15
  return { sql: "$1::text::jsonb", bound: JSON.stringify(value) };
12
16
  }
17
+ // JSON.stringify throws on bigint; its decimal string is a valid JSON number literal.
18
+ if (typeof value === "bigint") {
19
+ return { sql: "$1::text::jsonb", bound: value.toString() };
20
+ }
13
21
  return { sql: "$1::jsonb", bound: value };
14
22
  }
15
23
 
24
+ // Security invariant: aggregateId is a global row UUID, so without the tenant_id
25
+ // filter tenant A could mutate tenant B's row by its UUID (cf. removeCustomFieldKeyForTenant).
16
26
  export async function setCustomFieldValue(
17
27
  db: DbRunner,
18
28
  tableName: string,
19
29
  fieldKey: string,
20
30
  value: unknown,
21
31
  aggregateId: string,
32
+ tenantId: TenantId,
22
33
  ): Promise<void> {
23
34
  const tbl = quoteTable(tableName);
24
35
  const escapedKey = fieldKey.replace(/'/g, "''");
25
36
  const jsonb = bindJsonbParam(value);
26
37
  await asRawClient(db).unsafe(
27
- `UPDATE ${tbl} SET custom_fields = jsonb_set(custom_fields, '{${escapedKey}}', ${jsonb.sql}, true) WHERE id = $2`,
28
- [jsonb.bound, aggregateId],
38
+ `UPDATE ${tbl} SET custom_fields = jsonb_set(custom_fields, '{${escapedKey}}', ${jsonb.sql}, true) WHERE id = $2 AND tenant_id = $3`,
39
+ [jsonb.bound, aggregateId, tenantId],
29
40
  );
30
41
  }
31
42
 
@@ -34,11 +45,12 @@ export async function clearCustomFieldKey(
34
45
  tableName: string,
35
46
  fieldKey: string,
36
47
  aggregateId: string,
48
+ tenantId: TenantId,
37
49
  ): Promise<void> {
38
50
  const tbl = quoteTable(tableName);
39
51
  await asRawClient(db).unsafe(
40
- `UPDATE ${tbl} SET custom_fields = custom_fields - $1 WHERE id = $2`,
41
- [fieldKey, aggregateId],
52
+ `UPDATE ${tbl} SET custom_fields = custom_fields - $1 WHERE id = $2 AND tenant_id = $3`,
53
+ [fieldKey, aggregateId, tenantId],
42
54
  );
43
55
  }
44
56
 
@@ -50,7 +62,7 @@ export async function removeCustomFieldKeyForTenant(
50
62
  db: DbRunner,
51
63
  tableName: string,
52
64
  fieldKey: string,
53
- tenantId: string,
65
+ tenantId: TenantId,
54
66
  ): Promise<void> {
55
67
  const tbl = quoteTable(tableName);
56
68
  await asRawClient(db).unsafe(
@@ -25,15 +25,29 @@ export async function selectHostRowsWithCustomFields(
25
25
  return Array.isArray(rowsResult) ? rowsResult : [];
26
26
  }
27
27
 
28
- export async function updateHostRowCustomFields(
28
+ export async function applyRetentionRemovals(
29
29
  db: DbRunner,
30
30
  tableName: string,
31
- customFields: Record<string, unknown>,
31
+ deleteKeys: readonly string[],
32
+ anonymizeKeys: readonly string[],
32
33
  rowId: string,
33
34
  ): Promise<void> {
34
35
  const quoted = `"${tableName.replace(/"/g, '""')}"`;
35
- await asRawClient(db).unsafe(`UPDATE ${quoted} SET custom_fields = $1::jsonb WHERE id = $2`, [
36
- customFields,
37
- rowId,
38
- ]);
36
+ // Atomic per-row jsonb edit instead of read-modify-write of the whole
37
+ // object: delete-keys are dropped (`- $1::text[]`), anonymize-keys are set
38
+ // to JSON null via a merge patch. Operating on the live row value preserves
39
+ // a concurrent set-custom-field on any *other* key — no lost update.
40
+ await asRawClient(db).unsafe(
41
+ `UPDATE ${quoted} SET custom_fields = CASE
42
+ WHEN jsonb_typeof(custom_fields) = 'object' THEN
43
+ (custom_fields - $1::text[])
44
+ || COALESCE(
45
+ (SELECT jsonb_object_agg(k, 'null'::jsonb) FROM unnest($2::text[]) AS k),
46
+ '{}'::jsonb
47
+ )
48
+ ELSE custom_fields
49
+ END
50
+ WHERE id = $3`,
51
+ [deleteKeys, anonymizeKeys, rowId],
52
+ );
39
53
  }
@@ -0,0 +1,10 @@
1
+ import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { fieldDefinitionEntity } from "./entity";
3
+
4
+ // Single field-definition executor shared by the four define/delete handlers.
5
+ // createEntityExecutor is side-effect-free; instantiating it once keeps the
6
+ // table+executor pair in one place instead of rebuilding it per handler module.
7
+ export const { executor: fieldDefinitionExecutor } = createEntityExecutor(
8
+ "field-definition",
9
+ fieldDefinitionEntity,
10
+ );