@cosmicdrift/kumiko-bundled-features 0.145.1 → 0.146.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 (37) hide show
  1. package/package.json +7 -6
  2. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +15 -55
  3. package/src/custom-fields/__tests__/parse-serialized-field.test.ts +11 -3
  4. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +24 -79
  5. package/src/custom-fields/db/queries/user-data-rights.ts +0 -20
  6. package/src/custom-fields/events.ts +4 -9
  7. package/src/custom-fields/feature.ts +8 -0
  8. package/src/custom-fields/handlers/set-custom-field.write.ts +3 -44
  9. package/src/custom-fields/handlers/update-tenant-field.write.ts +0 -27
  10. package/src/custom-fields/lib/parse-serialized-field.ts +12 -4
  11. package/src/custom-fields/lib/value-schema.ts +1 -1
  12. package/src/custom-fields/schemas.ts +9 -9
  13. package/src/custom-fields/wire-for-entity.ts +8 -15
  14. package/src/custom-fields/wire-user-data-rights.ts +5 -54
  15. package/src/inbound-mail-foundation/__tests__/retention.integration.test.ts +199 -0
  16. package/src/inbound-mail-foundation/feature.ts +58 -0
  17. package/src/inbound-mail-foundation/retention-sweep.ts +155 -0
  18. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +26 -0
  19. package/src/managed-pages/feature.ts +3 -1
  20. package/src/managed-pages/handlers/by-tenant-published.query.ts +52 -0
  21. package/src/page-render/__tests__/layout.test.ts +53 -0
  22. package/src/page-render/index.ts +1 -1
  23. package/src/page-render/layout.ts +15 -1
  24. package/src/seo/__tests__/llms-txt.test.ts +38 -0
  25. package/src/seo/__tests__/robots-txt.test.ts +18 -0
  26. package/src/seo/__tests__/schema-builders.test.ts +57 -0
  27. package/src/seo/__tests__/seo.integration.test.ts +258 -0
  28. package/src/seo/__tests__/sitemap.test.ts +38 -0
  29. package/src/seo/constants.ts +60 -0
  30. package/src/seo/feature.ts +313 -0
  31. package/src/seo/handlers/seo-config.query.ts +35 -0
  32. package/src/seo/index.ts +19 -0
  33. package/src/seo/llms-txt.ts +33 -0
  34. package/src/seo/robots-txt.ts +13 -0
  35. package/src/seo/schema-builders.ts +57 -0
  36. package/src/seo/sitemap.ts +30 -0
  37. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +39 -62
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.145.1",
3
+ "version": "0.146.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>",
@@ -107,14 +107,15 @@
107
107
  "./managed-pages": "./src/managed-pages/index.ts",
108
108
  "./managed-pages/web": "./src/managed-pages/web/index.ts",
109
109
  "./managed-pages/seeding": "./src/managed-pages/seeding.ts",
110
+ "./seo": "./src/seo/index.ts",
110
111
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
111
112
  },
112
113
  "dependencies": {
113
- "@cosmicdrift/kumiko-dispatcher-live": "0.145.1",
114
- "@cosmicdrift/kumiko-framework": "0.145.1",
115
- "@cosmicdrift/kumiko-headless": "0.145.1",
116
- "@cosmicdrift/kumiko-renderer": "0.145.1",
117
- "@cosmicdrift/kumiko-renderer-web": "0.145.1",
114
+ "@cosmicdrift/kumiko-dispatcher-live": "0.146.0",
115
+ "@cosmicdrift/kumiko-framework": "0.146.0",
116
+ "@cosmicdrift/kumiko-headless": "0.146.0",
117
+ "@cosmicdrift/kumiko-renderer": "0.146.0",
118
+ "@cosmicdrift/kumiko-renderer-web": "0.146.0",
118
119
  "@mollie/api-client": "^4.5.0",
119
120
  "imapflow": "^1.3.3",
120
121
  "mailparser": "^3.9.8",
@@ -435,9 +435,9 @@ describe("custom-fields integration — define resurrection (B1)", () => {
435
435
  });
436
436
  });
437
437
 
438
- describe("custom-fields integration — sensitive value self-projected, kept out of the event log (#2)", () => {
439
- test("sensitive field: value reaches the projection but NOT kumiko_events", async () => {
440
- await stack.http.writeOk(
438
+ describe("custom-fields integration — no PII: `sensitive` rejected, every value in the log (#972)", () => {
439
+ test("define-tenant-field with `sensitive` is rejected at the schema boundary", async () => {
440
+ const err = await stack.http.writeErr(
441
441
  "custom-fields:write:define-tenant-field",
442
442
  {
443
443
  entityName: "property",
@@ -449,23 +449,14 @@ describe("custom-fields integration — sensitive value self-projected, kept out
449
449
  },
450
450
  admin,
451
451
  );
452
- const propId = "aaaaaaaa-aaaa-4000-8000-0000000000a1";
453
- await createProperty(propId, "Sensitive Prop");
454
- await setCustomField("property", propId, "taxId", "DE-TAX-999");
455
-
456
- // Read model HAS the value — self-projected directly into the host row.
457
- const props = await listProperties();
458
- const row = props.rows.find((r) => r["id"] === propId);
459
- expect(row?.["taxId"]).toBe("DE-TAX-999");
460
-
461
- // The immutable event log does NOT carry the value (erasable by design).
462
- const payload = await fetchSetEventPayload(propId, "taxId");
463
- expect(payload).toBeDefined();
464
- expect(payload?.["fieldKey"]).toBe("taxId");
465
- expect(payload && "value" in payload).toBe(false);
452
+ expect(err.httpStatus).toBe(400);
453
+ expect(err.code).toBe("validation_error");
454
+ // The API sanitises zod messages to i18n keys — the reject surfaces as a
455
+ // custom validation issue on the serializedField path.
456
+ expect(JSON.stringify(err.details)).toContain('"serializedField"');
466
457
  });
467
458
 
468
- test("non-sensitive field: value IS in the event log (control)", async () => {
459
+ test("every set carries its value in the event log", async () => {
469
460
  const propId = "aaaaaaaa-aaaa-4000-8000-0000000000a2";
470
461
  await defineField("property", "publicNote", "text");
471
462
  await createProperty(propId, "Public Prop");
@@ -475,24 +466,13 @@ describe("custom-fields integration — sensitive value self-projected, kept out
475
466
  expect(payload?.["value"]).toBe("visible");
476
467
  });
477
468
 
478
- test("rebuild replay: re-applying logged events restores non-sensitive, not sensitive", async () => {
469
+ test("rebuild replay: re-applying logged events restores ALL custom-field values", async () => {
479
470
  await defineField("property", "publicTag", "text");
480
- await stack.http.writeOk(
481
- "custom-fields:write:define-tenant-field",
482
- {
483
- entityName: "property",
484
- fieldKey: "secret",
485
- serializedField: { type: "text", sensitive: true },
486
- required: false,
487
- searchable: false,
488
- displayOrder: 0,
489
- },
490
- admin,
491
- );
471
+ await defineField("property", "vendorRef", "text");
492
472
  const propId = "aaaaaaaa-aaaa-4000-8000-0000000000a3";
493
473
  await createProperty(propId, "Rebuild Prop");
494
474
  await setCustomField("property", propId, "publicTag", "keepme");
495
- await setCustomField("property", propId, "secret", "DE-TAX-777");
475
+ await setCustomField("property", propId, "vendorRef", "VR-777");
496
476
  await stack.eventDispatcher?.runOnce();
497
477
 
498
478
  // Wipe the read model, then replay the logged events through the REAL MSP
@@ -531,30 +511,10 @@ describe("custom-fields integration — sensitive value self-projected, kept out
531
511
  }
532
512
 
533
513
  const row = (await listProperties()).rows.find((r) => r["id"] === propId);
534
- // Non-sensitive: restored from its value-bearing event.
514
+ // Every value restores from its value-bearing event — custom fields are
515
+ // fully rebuild-safe since #972 (no PII, no value-less events).
535
516
  expect(row?.["publicTag"]).toBe("keepme");
536
- // Sensitive: the logged event carried no value → gone. The accepted, DURABLE
537
- // rebuild-loss — and why a forget can't be undone by a rebuild.
538
- expect(row?.["secret"]).toBeUndefined();
539
- });
540
-
541
- test("update-tenant-field rejects flipping `sensitive` (would orphan logged PII)", async () => {
542
- await defineField("property", "maybePii", "text"); // non-sensitive at definition
543
- const err = await stack.http.writeErr(
544
- "custom-fields:write:update-tenant-field",
545
- {
546
- entityName: "property",
547
- fieldKey: "maybePii",
548
- serializedField: { type: "text", sensitive: true }, // attempt the flip
549
- required: false,
550
- searchable: false,
551
- displayOrder: 0,
552
- },
553
- admin,
554
- );
555
- expect(err.httpStatus).toBe(422);
556
- expect(err.code).toBe("unprocessable");
557
- expect(err.details).toMatchObject({ reason: "field_sensitive_immutable" });
517
+ expect(row?.["vendorRef"]).toBe("VR-777");
558
518
  });
559
519
  });
560
520
 
@@ -3,8 +3,16 @@ import { isFieldDefinitionRow, parseSerializedField } from "../lib/parse-seriali
3
3
 
4
4
  describe("parseSerializedField", () => {
5
5
  test("parses a valid JSON string into the typed shape", () => {
6
- const parsed = parseSerializedField('{"type":"text","sensitive":true}');
7
- expect(parsed).toEqual({ type: "text", sensitive: true });
6
+ const parsed = parseSerializedField(
7
+ '{"type":"text","retention":{"keepFor":"30d","strategy":"delete"}}',
8
+ );
9
+ expect(parsed).toEqual({ type: "text", retention: { keepFor: "30d", strategy: "delete" } });
10
+ });
11
+
12
+ test("throws on a stored definition with the removed `sensitive` key (#972)", () => {
13
+ expect(() => parseSerializedField('{"type":"text","sensitive":true}')).toThrow(
14
+ /custom fields don't support PII/,
15
+ );
8
16
  });
9
17
 
10
18
  test("accepts an already-parsed object (jsonb-tolerant driver path)", () => {
@@ -17,7 +25,7 @@ describe("parseSerializedField", () => {
17
25
  });
18
26
 
19
27
  test("returns null when the shape lacks a string type", () => {
20
- expect(parseSerializedField('{"sensitive":true}')).toBeNull();
28
+ expect(parseSerializedField('{"fieldAccess":{}}')).toBeNull();
21
29
  expect(parseSerializedField({ type: 42 })).toBeNull();
22
30
  });
23
31
 
@@ -2,24 +2,16 @@
2
2
  // REAL export/forget runners (runUserExport / runForgetCleanup), not by
3
3
  // calling the registered hooks in isolation.
4
4
  //
5
- // What the real runners prove that direct hook calls cannot:
5
+ // Since #972 the wiring is EXPORT-ONLY: custom fields carry supplemental
6
+ // business data, not PII — runForgetCleanup has nothing to redact in the
7
+ // customFields jsonb. The tests prove:
6
8
  //
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
+ // * runUserExport picks up the custom-fields export hook from the registry
10
+ // and folds its snippet into the user's cross-tenant bundle (Art. 20).
9
11
  //
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.
18
- //
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).
12
+ // * runForgetCleanup leaves customFields untouched on anonymize (only the
13
+ // host hook nulls the owner) and lets the host delete-hook remove whole
14
+ // rows on strategy "delete".
23
15
 
24
16
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
25
17
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
@@ -76,9 +68,8 @@ const propertyEntity = createEntity({
76
68
  const propertyTable = buildEntityTable("property", propertyEntity);
77
69
 
78
70
  // Host entity gets its own EXT_USER_DATA-registration too — that's the
79
- // canonical setup. The host's anonymize hook NULLS inserted_by_id (default
80
- // order 0); the custom-fields strip (order -100) must run first. Both fire in
81
- // the same runForgetCleanup sub-transaction.
71
+ // canonical setup. The host's anonymize hook NULLS inserted_by_id; the
72
+ // custom-fields wiring contributes export only (#972).
82
73
  const hostExportHook: UserDataExportHook = async (ctx) => {
83
74
  const rows = await asRawClient(ctx.db).unsafe(
84
75
  `SELECT id, name FROM read_t15c_properties WHERE inserted_by_id = $1 AND tenant_id = $2`,
@@ -99,9 +90,7 @@ const hostDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
99
90
  [ctx.userId, ctx.tenantId],
100
91
  );
101
92
  } else {
102
- // anonymize: clear owner, keep row + non-sensitive customFields. Runs AFTER
103
- // the custom-fields strip (order -100 < 0) — if it ran first, the strip's
104
- // owner-keyed WHERE would match nothing.
93
+ // anonymize: clear owner, keep row + customFields.
105
94
  await asRawClient(ctx.db).unsafe(
106
95
  `UPDATE read_t15c_properties SET inserted_by_id = NULL WHERE inserted_by_id = $1 AND tenant_id = $2`,
107
96
  [ctx.userId, ctx.tenantId],
@@ -304,10 +293,10 @@ describe("T1.5c: custom-fields user-data-rights through the real runners", () =>
304
293
  test("export: customFields jsonb lands in the user's export bundle", async () => {
305
294
  await seedActiveUserWithMembership();
306
295
  const propertyId = "11111111-1111-4000-8000-000000000001";
307
- await defineField("email", { type: "text", sensitive: true });
296
+ await defineField("contactRef", { type: "text" });
308
297
  await defineField("vipFlag", { type: "boolean" });
309
298
  await createProperty(propertyId, "Hofgarten 12");
310
- await setField(propertyId, "email", "alice@example.com");
299
+ await setField(propertyId, "contactRef", "crm-4711");
311
300
  await setField(propertyId, "vipFlag", true);
312
301
  await stack.eventDispatcher?.runOnce();
313
302
 
@@ -324,19 +313,19 @@ describe("T1.5c: custom-fields user-data-rights through the real runners", () =>
324
313
  expect(cfSnippet).toBeDefined();
325
314
  expect(cfSnippet?.rows).toHaveLength(1);
326
315
  expect(cfSnippet?.rows[0]?.["customFields"]).toMatchObject({
327
- email: "alice@example.com",
316
+ contactRef: "crm-4711",
328
317
  vipFlag: true,
329
318
  });
330
319
  });
331
320
 
332
- test("forget anonymize: strip runs BEFORE host owner-nulling → sensitive key gone, non-sensitive kept", async () => {
321
+ test("forget anonymize: customFields stay fully intact (export-only wiring, #972)", async () => {
333
322
  await seedForgetUserWithMembership();
334
323
  await seedPropertyAnonymizeOverride();
335
324
  const propertyId = "22222222-2222-4000-8000-000000000002";
336
- await defineField("email", { type: "text", sensitive: true });
325
+ await defineField("contactRef", { type: "text" });
337
326
  await defineField("vipFlag", { type: "boolean" });
338
327
  await createProperty(propertyId, "Anonymize-Me");
339
- await setField(propertyId, "email", "alice@example.com");
328
+ await setField(propertyId, "contactRef", "crm-4711");
340
329
  await setField(propertyId, "vipFlag", true);
341
330
  await stack.eventDispatcher?.runOnce();
342
331
 
@@ -349,39 +338,19 @@ describe("T1.5c: custom-fields user-data-rights through the real runners", () =>
349
338
  expect(result.errors).toHaveLength(0);
350
339
 
351
340
  const row = await readRow(propertyId);
352
- // Host hook ran (owner nulled), and the strip ran BEFORE it (sensitive
353
- // key removed despite the owner-keyed WHERE proof of the -100 ordering).
341
+ // Host hook ran (owner nulled); custom fields hold business data, not
342
+ // PIInothing was stripped.
354
343
  expect(row?.["inserted_by_id"]).toBeNull();
355
- const customFields = row?.["custom_fields"] as Record<string, unknown> | undefined;
356
- expect(customFields).toBeDefined();
357
- expect(customFields).not.toHaveProperty("email");
358
- expect(customFields).toMatchObject({ vipFlag: true });
359
-
360
- // The other half of erasure: the sensitive value was self-projected and the
361
- // customField.set event was persisted value-less — so PII never entered the
362
- // immutable log. Without this, the strip above would be undone by a rebuild.
363
- const eventRows = await asRawClient(stack.db).unsafe(
364
- "SELECT payload FROM kumiko_events WHERE aggregate_id = $1",
365
- [propertyId],
366
- );
367
- const setPayloads = (eventRows as ReadonlyArray<{ payload: unknown }>).map((r) =>
368
- typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload,
369
- ) as Array<Record<string, unknown>>;
370
- const emailSet = setPayloads.find((p) => p?.["fieldKey"] === "email");
371
- expect(emailSet).toBeDefined();
372
- expect(emailSet && "value" in emailSet).toBe(false);
373
- // Control: the non-sensitive value DID go through the log (normal path).
374
- const vipSet = setPayloads.find((p) => p?.["fieldKey"] === "vipFlag");
375
- expect(vipSet?.["value"]).toBe(true);
344
+ expect(row?.["custom_fields"]).toMatchObject({ contactRef: "crm-4711", vipFlag: true });
376
345
  });
377
346
 
378
- test("forget delete (no override → strategy delete): host removes the row, strip is a no-op", async () => {
347
+ test("forget delete (no override → strategy delete): host removes the row", async () => {
379
348
  await seedForgetUserWithMembership();
380
349
  // No retention override → policyToStrategy(null) = "delete".
381
350
  const propertyId = "33333333-3333-4000-8000-000000000003";
382
- await defineField("email", { type: "text", sensitive: true });
351
+ await defineField("contactRef", { type: "text" });
383
352
  await createProperty(propertyId, "Delete-Me");
384
- await setField(propertyId, "email", "alice@example.com");
353
+ await setField(propertyId, "contactRef", "crm-4711");
385
354
  await stack.eventDispatcher?.runOnce();
386
355
 
387
356
  const result = await runForgetCleanup({
@@ -391,8 +360,7 @@ describe("T1.5c: custom-fields user-data-rights through the real runners", () =>
391
360
  });
392
361
  expect(result.processedUserIds).toContain(admin.id);
393
362
 
394
- // Host delete-hook removed the row; custom-fields strip stayed out of the
395
- // way (it returns early on strategy=delete).
363
+ // Host delete-hook removed the row wholesale customFields go with it.
396
364
  expect(await readRow(propertyId)).toBeUndefined();
397
365
  });
398
366
 
@@ -413,27 +381,4 @@ describe("T1.5c: custom-fields user-data-rights through the real runners", () =>
413
381
  const cfSnippet = tenantSection?.entities.find((e) => e.entity === "property.customFields");
414
382
  expect(cfSnippet).toBeUndefined();
415
383
  });
416
-
417
- test("forget anonymize without sensitive fields defined → all customFields kept", async () => {
418
- await seedForgetUserWithMembership();
419
- await seedPropertyAnonymizeOverride();
420
- const propertyId = "55555555-5555-4000-8000-000000000005";
421
- await defineField("nonSensitive", { type: "text" });
422
- await createProperty(propertyId, "AllStay");
423
- await setField(propertyId, "nonSensitive", "still-here");
424
- await stack.eventDispatcher?.runOnce();
425
-
426
- const result = await runForgetCleanup({
427
- db: stack.db,
428
- registry: stack.registry,
429
- now: NOW(),
430
- });
431
- expect(result.processedUserIds).toContain(admin.id);
432
-
433
- const row = await readRow(propertyId);
434
- expect(row?.["inserted_by_id"]).toBeNull();
435
- expect((row?.["custom_fields"] as Record<string, unknown>)?.["nonSensitive"]).toBe(
436
- "still-here",
437
- );
438
- });
439
384
  });
@@ -25,26 +25,6 @@ export async function selectCustomFieldsHostRows(
25
25
  return Array.isArray(rowsResult) ? rowsResult : [];
26
26
  }
27
27
 
28
- export async function stripSensitiveCustomFieldKeys(
29
- db: DbRunner,
30
- tableName: string,
31
- userIdColumn: string,
32
- sensitiveKeys: readonly string[],
33
- userId: string,
34
- tenantId: string,
35
- ): Promise<void> {
36
- const tbl = quoteTable(tableName);
37
- const userCol = quoteColumn(userIdColumn);
38
- await asRawClient(db).unsafe(
39
- `UPDATE ${tbl} SET custom_fields = CASE
40
- WHEN jsonb_typeof(custom_fields) = 'object' THEN custom_fields - $1::text[]
41
- ELSE custom_fields
42
- END
43
- WHERE ${userCol} = $2 AND tenant_id = $3`,
44
- [sensitiveKeys, userId, tenantId],
45
- );
46
- }
47
-
48
28
  export async function selectFieldDefinitionsForEntity(
49
29
  db: DbRunner,
50
30
  entityName: string,
@@ -11,16 +11,11 @@ import { z } from "zod";
11
11
 
12
12
  export const customFieldSetSchema = z.object({
13
13
  fieldKey: z.string().min(1).max(64),
14
- // Optional: a `sensitive`-field set persists a VALUE-LESS event the value is
15
- // self-projected into the host row by the write handler and must never enter
16
- // the immutable log. Non-sensitive sets always carry the value.
14
+ // Every set carries its value (custom fields hold no PII, #972) — replay
15
+ // reproduces the projection fully. Stays optional at the type layer only
16
+ // because z.unknown() is inherently optional; the apply side treats
17
+ // `value === undefined` as an anomaly and warns.
17
18
  value: z.unknown().optional(),
18
- // Explicit protocol marker (527/1) — `value === undefined` alone is also a
19
- // valid-looking "clear" shape; `_sensitive` names the reason so a future
20
- // reader can't mistake one for the other. Optional so historical events
21
- // (persisted before this field existed) still parse: apply-side readers
22
- // keep `value === undefined` as their actual branch condition.
23
- _sensitive: z.literal(true).optional(),
24
19
  });
25
20
  export type CustomFieldSetPayload = z.infer<typeof customFieldSetSchema>;
26
21
 
@@ -1,6 +1,14 @@
1
1
  // custom-fields — Tenant- + System-scoped Custom-Field-Definitions +
2
2
  // generische Custom-Field-VALUE write-handler (host-stream-events).
3
3
  //
4
+ // **Scope: supplemental business data, NO PII (#972).** Custom fields sind
5
+ // für tenant-spezifische Zusatzinfos (Referenznummern, Flags, Kategorien) —
6
+ // nicht für personenbezogene Daten. Das frühere `sensitive`-Flag wurde
7
+ // entfernt; das Schema rejected es und `parseSerializedField` wirft auf
8
+ // Alt-Definitionen. Werte stehen vollständig im Event-Log (rebuild-safe).
9
+ // PII gehört in Schema-Entity-Felder mit pii/userOwned/tenantOwned-
10
+ // Annotation (Crypto-Shredding, Event-Ciphertext, Forget-Pipeline).
11
+ //
4
12
  // **Was diese Feature liefert (B1 + B2, 2026-05-23):**
5
13
  // 1. r.entity("field-definition") — Definition-Storage (event-sourced).
6
14
  // 2. define-tenant-field / define-system-field — RBAC write-handlers für
@@ -1,9 +1,7 @@
1
- import { buildEntityTable, extractTableName } from "@cosmicdrift/kumiko-framework/db";
2
1
  import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
4
3
  import { z } from "zod";
5
4
  import { DEFAULT_VALUE_WRITE_ROLES } from "../constants";
6
- import { setCustomFieldValue } from "../db/queries/projection";
7
5
  import { customFieldsFeature } from "../feature";
8
6
  import { fieldWriteAccessDeniedRoles, loadFieldDefinition } from "../lib/field-access";
9
7
  import { buildCustomFieldValueSchema } from "../lib/value-schema";
@@ -88,55 +86,16 @@ export const setCustomFieldHandler: WriteHandlerDef = {
88
86
  }
89
87
  }
90
88
 
91
- // PII (`sensitive: true` definition): self-project the value here —
92
- // synchronously, from the in-memory value — exactly like the entity executor
93
- // does for sensitive entity fields. The persisted customField.set event then
94
- // omits the value, so PII never enters the immutable event log; the existing
95
- // user-data-rights strip of the projection erases it durably. Trade-off: a
96
- // projection rebuild replays the value-less event and the MSP skips it (see
97
- // wire-for-entity), so the value is gone — identical to a sensitive entity
98
- // field. The host table isn't known to this generic handler, so resolve it
99
- // per-stack via the registry (no module-global state).
100
- const sensitive = loaded.field.sensitive === true;
101
- if (sensitive) {
102
- const entity = ctx.registry.getEntity(payload.entityName);
103
- if (!entity) {
104
- // Fail closed: without the host table we cannot self-project, and must
105
- // NOT fall back to writing the value into the event log.
106
- return failUnprocessable("custom_field_host_unresolved", {
107
- entityName: payload.entityName,
108
- });
109
- }
110
- // Resolves the same canonical table name the MSP/postQuery use (the table
111
- // NAME, not the drizzle object). Holds unless a host entity is wired with
112
- // a custom backing table whose name diverges from its definition — rare,
113
- // and the MSP path makes the same assumption.
114
- const tableName = extractTableName(
115
- buildEntityTable(payload.entityName, entity),
116
- "custom-fields/set-custom-field",
117
- );
118
- await setCustomFieldValue(
119
- ctx.db.raw,
120
- tableName,
121
- payload.fieldKey,
122
- payload.value,
123
- payload.entityId,
124
- event.user.tenantId,
125
- );
126
- }
127
-
128
89
  // Emit customField.set on host-aggregate stream. unsafeAppendEvent
129
90
  // (statt strict appendEvent) weil event-type-map keine cross-feature-
130
91
  // augmentation für diesen event-typ hat — wir nutzen den qualified
131
- // string-namen direkt. Sensitive fields persist a value-less event (the
132
- // value was self-projected above and must stay out of the log).
92
+ // string-namen direkt. Der Wert steht IMMER im Event (custom fields
93
+ // tragen keine PII, #972) Replay reproduziert die Projection voll.
133
94
  await ctx.unsafeAppendEvent({
134
95
  aggregateId: payload.entityId,
135
96
  aggregateType: payload.entityName,
136
97
  type: customFieldsFeature.exports.setEvent.name,
137
- payload: sensitive
138
- ? { fieldKey: payload.fieldKey, _sensitive: true as const }
139
- : { fieldKey: payload.fieldKey, value: payload.value },
98
+ payload: { fieldKey: payload.fieldKey, value: payload.value },
140
99
  });
141
100
 
142
101
  return {
@@ -3,7 +3,6 @@ import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/e
3
3
  import { fieldDefinitionAggregateId } from "../aggregate-id";
4
4
  import { fieldDefinitionExecutor } from "../executor";
5
5
  import { buildFieldDefinitionColumns } from "../lib/field-definition-row";
6
- import { parseSerializedField } from "../lib/parse-serialized-field";
7
6
  import { type UpdateFieldPayload, updateFieldPayloadSchema } from "../schemas";
8
7
 
9
8
  // update-tenant-field — TenantAdmin ersetzt den Stand einer bestehenden
@@ -60,32 +59,6 @@ export const updateTenantFieldHandler: WriteHandlerDef = {
60
59
  });
61
60
  }
62
61
 
63
- // `sensitive` is immutable. Flipping it on an existing field would leave a
64
- // GDPR hole: a non-sensitive→sensitive switch can't retroactively erase the
65
- // values already written to host rows by past sets (set-custom-field only
66
- // routes the PII-safe path at write time). To change sensitivity, delete +
67
- // re-define (the honest cut — same rationale as the type guard above).
68
- //
69
- // 527/2 — even that "honest cut" is incomplete: it clears the projection
70
- // row, but kumiko_events is immutable, so historical customField.set
71
- // events from the field's non-sensitive era keep their plaintext values
72
- // in the log forever. There is no event-payload redaction mechanism for
73
- // this today (EventStoreExecutor.forget() hard-deletes a projection row
74
- // and is rebuild-safe, but doesn't touch or redact past event payloads).
75
- // A tenant that genuinely needs those values erased needs an operator-run
76
- // data-retention pass over the raw event log — not something this
77
- // handler, or delete-and-redefine, can give them.
78
- const currentSensitive = parseSerializedField(existing["serializedField"])?.sensitive === true;
79
- const requestedSensitive = payload.serializedField.sensitive === true;
80
- if (currentSensitive !== requestedSensitive) {
81
- return failUnprocessable("field_sensitive_immutable", {
82
- entityName: payload.entityName,
83
- fieldKey: payload.fieldKey,
84
- currentSensitive,
85
- requestedSensitive,
86
- });
87
- }
88
-
89
62
  // entityName/fieldKey sind die Identität — nicht Teil der changes.
90
63
  const {
91
64
  entityName: _entityName,
@@ -8,8 +8,8 @@
8
8
  // across the bundle.
9
9
  //
10
10
  // All structured `serializedField`-keys recognised today (`fieldAccess`,
11
- // `sensitive`, `retention`) live on this shape. New keys go here so the
12
- // other call-sites can read them via the typed result instead of re-parsing.
11
+ // `retention`) live on this shape. New keys go here so the other call-sites
12
+ // can read them via the typed result instead of re-parsing.
13
13
 
14
14
  import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
15
15
 
@@ -19,7 +19,6 @@ export interface SerializedFieldShape {
19
19
  readonly read?: ReadonlyArray<string>;
20
20
  readonly write?: ReadonlyArray<string>;
21
21
  };
22
- readonly sensitive?: boolean;
23
22
  readonly retention?: {
24
23
  readonly keepFor: string;
25
24
  readonly strategy: "delete" | "anonymize";
@@ -41,7 +40,16 @@ function isShape(v: unknown): v is SerializedFieldShape {
41
40
  */
42
41
  export function parseSerializedField(raw: unknown): SerializedFieldShape | null {
43
42
  const parsed = typeof raw === "string" ? parseJsonSafe<unknown>(raw, null) : raw;
44
- return isShape(parsed) ? parsed : null;
43
+ if (!isShape(parsed)) return null;
44
+ // Fail loud instead of silently dropping the key: a stored definition with
45
+ // `sensitive` predates #972 and its PII expectations (event-log exclusion,
46
+ // forget-strip) no longer hold — the field must be recreated.
47
+ if ("sensitive" in parsed) {
48
+ throw new Error(
49
+ "custom-field definition contains the removed `sensitive` key (#972) — custom fields don't support PII. Recreate the field without it; model personal data as a schema entity field with a pii annotation.",
50
+ );
51
+ }
52
+ return parsed;
45
53
  }
46
54
 
47
55
  export interface FieldDefinitionRow {
@@ -86,6 +86,6 @@ export function buildCustomFieldValueSchema(parsedField: unknown): z.ZodTypeAny
86
86
 
87
87
  // @cast-boundary serialized-field is the dehydrated r.field.X() output =
88
88
  // a FieldDefinition; fieldToZod reads only its type-specific keys (the
89
- // extra fieldAccess/sensitive/retention/label keys are ignored).
89
+ // extra fieldAccess/retention/label keys are ignored).
90
90
  return fieldToZod(fieldDef as unknown as FieldDefinition, currencies);
91
91
  }
@@ -33,18 +33,14 @@ export type CustomFieldAccess = z.infer<typeof customFieldAccessSchema>;
33
33
  // money: { type: "money", required: false, currency: "EUR" }
34
34
  // embedded: { type: "embedded", required: false, schema: { ... } }
35
35
  //
36
- // `fieldAccess` (T1.5b) and `sensitive` (T1.5c) are the structured keys
37
- // recognised by the handlers / user-data-rights wiring. Everything else
38
- // stays loose pending B2's per-type discriminated-union.
36
+ // `fieldAccess` (T1.5b) is the structured key recognised by the handlers.
37
+ // Everything else stays loose pending B2's per-type discriminated-union
38
+ // EXCEPT `sensitive`, which is explicitly rejected below: custom fields
39
+ // carry supplemental business data only, PII support was removed (#972).
39
40
  const serializedFieldSchema = z
40
41
  .looseObject({
41
42
  type: fieldTypeSchema,
42
43
  fieldAccess: customFieldAccessSchema,
43
- // `sensitive: true` marks the field as PII — the user-data-rights
44
- // wiring (wireCustomFieldsUserDataRightsFor) removes its value from
45
- // the host row's customFields jsonb on user-forget when the strategy
46
- // is "anonymize". Non-sensitive fields are untouched.
47
- sensitive: z.boolean().optional(),
48
44
  // `retention` (T1.5d): per-field expiry. The retention-cron strips
49
45
  // values whose host-row `modified_at` is older than `keepFor`. Strategy
50
46
  // `delete` removes the key from the customFields jsonb; `anonymize`
@@ -59,7 +55,11 @@ const serializedFieldSchema = z
59
55
  })
60
56
  .optional(),
61
57
  })
62
- .refine((v) => typeof v["type"] === "string", "serializedField must have a string `type`");
58
+ .refine((v) => typeof v["type"] === "string", "serializedField must have a string `type`")
59
+ .refine(
60
+ (v) => !("sensitive" in v),
61
+ "custom fields don't support PII — `sensitive` was removed (#972). Model personal data as a schema entity field with a pii/userOwned/tenantOwned annotation instead.",
62
+ );
63
63
 
64
64
  // i18n-labels — `{ de: "...", en: "...", ... }`. Mindestens ein Eintrag.
65
65
  const labelSchema = z.record(z.string().min(2).max(8), z.string().min(1));
@@ -105,22 +105,15 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
105
105
  if (event.aggregateType !== entityName) return;
106
106
  const payload = event.payload as CustomFieldSetPayload; // @cast-boundary engine-payload
107
107
 
108
- // skip: sensitive fields self-project in the write handler (see
109
- // set-custom-field) and persist a value-less event so PII never enters
110
- // the log. Skipping is correct both live (the handler already wrote the
111
- // row) and on replay (the value is intentionally gone, the accepted
112
- // rebuild-loss). `value === undefined` stays the actual branch condition
113
- // (historical events predate `_sensitive`) — the warning below is a
114
- // canary (527/1): a NEW event missing both means something emitted a
115
- // value-less customField.set outside the sensitive-field path.
108
+ // skip: a value-less customField.set is an anomaly since #972 (every set
109
+ // carries its value; the sensitive self-projection path was removed)
110
+ // warn and leave the row untouched instead of binding undefined.
116
111
  if (payload.value === undefined) {
117
- if (payload._sensitive !== true) {
118
- // biome-ignore lint/suspicious/noConsole: boot-adjacent correctness canary, no logger available in an apply function
119
- console.warn(
120
- `[custom-fields] customField.set for "${payload.fieldKey}" on ${event.aggregateType}/${event.aggregateId} has no value and no _sensitive marker — skipping, but this event didn't come from the known sensitive-field path.`,
121
- );
122
- }
123
- // skip: value-less set, handled above (warned if unexpectedly so)
112
+ // biome-ignore lint/suspicious/noConsole: boot-adjacent correctness canary, no logger available in an apply function
113
+ console.warn(
114
+ `[custom-fields] customField.set for "${payload.fieldKey}" on ${event.aggregateType}/${event.aggregateId} has no value — skipping (value-less sets should not exist since #972).`,
115
+ );
116
+ // skip: warned above — leave the row untouched instead of binding undefined
124
117
  return;
125
118
  }
126
119