@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.
- package/package.json +7 -6
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +15 -55
- package/src/custom-fields/__tests__/parse-serialized-field.test.ts +11 -3
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +24 -79
- package/src/custom-fields/db/queries/user-data-rights.ts +0 -20
- package/src/custom-fields/events.ts +4 -9
- package/src/custom-fields/feature.ts +8 -0
- package/src/custom-fields/handlers/set-custom-field.write.ts +3 -44
- package/src/custom-fields/handlers/update-tenant-field.write.ts +0 -27
- package/src/custom-fields/lib/parse-serialized-field.ts +12 -4
- package/src/custom-fields/lib/value-schema.ts +1 -1
- package/src/custom-fields/schemas.ts +9 -9
- package/src/custom-fields/wire-for-entity.ts +8 -15
- package/src/custom-fields/wire-user-data-rights.ts +5 -54
- package/src/inbound-mail-foundation/__tests__/retention.integration.test.ts +199 -0
- package/src/inbound-mail-foundation/feature.ts +58 -0
- package/src/inbound-mail-foundation/retention-sweep.ts +155 -0
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +26 -0
- package/src/managed-pages/feature.ts +3 -1
- package/src/managed-pages/handlers/by-tenant-published.query.ts +52 -0
- package/src/page-render/__tests__/layout.test.ts +53 -0
- package/src/page-render/index.ts +1 -1
- package/src/page-render/layout.ts +15 -1
- package/src/seo/__tests__/llms-txt.test.ts +38 -0
- package/src/seo/__tests__/robots-txt.test.ts +18 -0
- package/src/seo/__tests__/schema-builders.test.ts +57 -0
- package/src/seo/__tests__/seo.integration.test.ts +258 -0
- package/src/seo/__tests__/sitemap.test.ts +38 -0
- package/src/seo/constants.ts +60 -0
- package/src/seo/feature.ts +313 -0
- package/src/seo/handlers/seo-config.query.ts +35 -0
- package/src/seo/index.ts +19 -0
- package/src/seo/llms-txt.ts +33 -0
- package/src/seo/robots-txt.ts +13 -0
- package/src/seo/schema-builders.ts +57 -0
- package/src/seo/sitemap.ts +30 -0
- 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.
|
|
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.
|
|
114
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
115
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
116
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
117
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
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
|
|
439
|
-
test("sensitive
|
|
440
|
-
await stack.http.
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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("
|
|
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
|
|
469
|
+
test("rebuild replay: re-applying logged events restores ALL custom-field values", async () => {
|
|
479
470
|
await defineField("property", "publicTag", "text");
|
|
480
|
-
await
|
|
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, "
|
|
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
|
-
//
|
|
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
|
-
|
|
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(
|
|
7
|
-
|
|
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('{"
|
|
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
|
-
//
|
|
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
|
|
8
|
-
//
|
|
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
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
|
80
|
-
//
|
|
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 +
|
|
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("
|
|
296
|
+
await defineField("contactRef", { type: "text" });
|
|
308
297
|
await defineField("vipFlag", { type: "boolean" });
|
|
309
298
|
await createProperty(propertyId, "Hofgarten 12");
|
|
310
|
-
await setField(propertyId, "
|
|
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
|
-
|
|
316
|
+
contactRef: "crm-4711",
|
|
328
317
|
vipFlag: true,
|
|
329
318
|
});
|
|
330
319
|
});
|
|
331
320
|
|
|
332
|
-
test("forget anonymize:
|
|
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("
|
|
325
|
+
await defineField("contactRef", { type: "text" });
|
|
337
326
|
await defineField("vipFlag", { type: "boolean" });
|
|
338
327
|
await createProperty(propertyId, "Anonymize-Me");
|
|
339
|
-
await setField(propertyId, "
|
|
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)
|
|
353
|
-
//
|
|
341
|
+
// Host hook ran (owner nulled); custom fields hold business data, not
|
|
342
|
+
// PII — nothing was stripped.
|
|
354
343
|
expect(row?.["inserted_by_id"]).toBeNull();
|
|
355
|
-
|
|
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
|
|
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("
|
|
351
|
+
await defineField("contactRef", { type: "text" });
|
|
383
352
|
await createProperty(propertyId, "Delete-Me");
|
|
384
|
-
await setField(propertyId, "
|
|
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
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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.
|
|
132
|
-
//
|
|
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:
|
|
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
|
-
// `
|
|
12
|
-
//
|
|
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
|
-
|
|
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/
|
|
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)
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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:
|
|
109
|
-
//
|
|
110
|
-
//
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|