@cosmicdrift/kumiko-bundled-features 0.64.0 → 0.66.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 (60) hide show
  1. package/package.json +6 -6
  2. package/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  3. package/src/config/__tests__/write-helpers.test.ts +152 -0
  4. package/src/config/handlers/readiness.query.ts +1 -0
  5. package/src/config/read-redaction.ts +0 -1
  6. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +195 -1
  7. package/src/custom-fields/__tests__/feature.test.ts +1 -4
  8. package/src/custom-fields/__tests__/field-write-access.test.ts +34 -0
  9. package/src/custom-fields/__tests__/parse-serialized-field.test.ts +45 -0
  10. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +17 -0
  11. package/src/custom-fields/db/queries/quota.ts +3 -1
  12. package/src/custom-fields/entity.ts +10 -3
  13. package/src/custom-fields/events.ts +4 -1
  14. package/src/custom-fields/feature.ts +1 -5
  15. package/src/custom-fields/handlers/define-system-field.write.ts +4 -3
  16. package/src/custom-fields/handlers/define-tenant-field.write.ts +4 -3
  17. package/src/custom-fields/handlers/set-custom-field.write.ts +44 -2
  18. package/src/custom-fields/handlers/update-tenant-field.write.ts +17 -0
  19. package/src/custom-fields/lib/define-or-resurrect.ts +52 -0
  20. package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +6 -4
  21. package/src/custom-fields/wire-for-entity.ts +7 -0
  22. package/src/files-provider-s3/__tests__/s3-provider.test.ts +7 -8
  23. package/src/files-provider-s3/s3-provider.ts +2 -4
  24. package/src/legal-pages/web/__tests__/client-plugin.test.ts +53 -0
  25. package/src/legal-pages/web/client-plugin.ts +9 -10
  26. package/src/managed-pages/handlers/set.write.ts +4 -11
  27. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +97 -0
  28. package/src/sessions/feature.ts +16 -3
  29. package/src/tags/__tests__/tags.integration.test.ts +30 -1
  30. package/src/tags/entity.ts +8 -0
  31. package/src/tags/handlers/assign-tag.write.ts +20 -5
  32. package/src/tags/web/__tests__/tag-section.test.tsx +26 -27
  33. package/src/tags/web/i18n.ts +6 -2
  34. package/src/tags/web/tag-section.tsx +87 -76
  35. package/src/text-content/web/__tests__/client-plugin.test.tsx +65 -0
  36. package/src/text-content/web/client-plugin.tsx +16 -13
  37. package/src/tier-engine/__tests__/resolver.integration.test.ts +67 -0
  38. package/src/tier-engine/__tests__/trial.test.ts +27 -0
  39. package/src/tier-engine/entity.ts +8 -0
  40. package/src/tier-engine/feature.ts +49 -9
  41. package/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -9
  42. package/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -9
  43. package/src/tier-engine/index.ts +1 -0
  44. package/src/tier-engine/trial.ts +26 -0
  45. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +233 -0
  46. package/src/user-data-rights/constants.ts +48 -0
  47. package/src/user-data-rights/feature.ts +15 -0
  48. package/src/user-data-rights/handlers/cancel-deletion.write.ts +10 -14
  49. package/src/user-data-rights/handlers/deletion-grace-period.ts +6 -7
  50. package/src/user-data-rights/handlers/lift-restriction.write.ts +3 -2
  51. package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +5 -7
  52. package/src/user-data-rights/handlers/restrict-account.write.ts +3 -7
  53. package/src/user-data-rights/index.ts +3 -0
  54. package/src/user-data-rights/lib/update-user-lifecycle.ts +100 -0
  55. package/src/user-data-rights/run-forget-cleanup.ts +3 -2
  56. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +256 -0
  57. package/src/user-data-rights/web/client-plugin.tsx +30 -0
  58. package/src/user-data-rights/web/i18n.ts +95 -0
  59. package/src/user-data-rights/web/index.ts +2 -0
  60. package/src/user-data-rights/web/privacy-center-screen.tsx +403 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.64.0",
3
+ "version": "0.66.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>",
@@ -84,11 +84,11 @@
84
84
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
85
85
  },
86
86
  "dependencies": {
87
- "@cosmicdrift/kumiko-dispatcher-live": "0.57.2",
88
- "@cosmicdrift/kumiko-framework": "0.57.2",
89
- "@cosmicdrift/kumiko-headless": "0.57.2",
90
- "@cosmicdrift/kumiko-renderer": "0.57.2",
91
- "@cosmicdrift/kumiko-renderer-web": "0.57.2",
87
+ "@cosmicdrift/kumiko-dispatcher-live": "0.66.0",
88
+ "@cosmicdrift/kumiko-framework": "0.66.0",
89
+ "@cosmicdrift/kumiko-headless": "0.66.0",
90
+ "@cosmicdrift/kumiko-renderer": "0.66.0",
91
+ "@cosmicdrift/kumiko-renderer-web": "0.66.0",
92
92
  "@mollie/api-client": "^4.5.0",
93
93
  "@node-rs/argon2": "^2.0.2",
94
94
  "@types/nodemailer": "^8.0.0",
@@ -98,6 +98,7 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
98
98
  // client can observe it through the HTTP surface.
99
99
  if (!user || user.isDeleted || !user.email || spec.extraSilentSkip(user)) {
100
100
  const data: TokenRequestData<TSuccessKind> = { kind: "no-op" };
101
+ // skip: silent no-op — uniform response prevents user-enumeration probing
101
102
  return { isSuccess: true, data };
102
103
  }
103
104
 
@@ -0,0 +1,152 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ ConfigScopes,
4
+ createTenantConfig,
5
+ SYSTEM_ROLE,
6
+ SYSTEM_TENANT_ID,
7
+ type TenantId,
8
+ } from "@cosmicdrift/kumiko-framework/engine";
9
+ import type { KumikoError } from "@cosmicdrift/kumiko-framework/errors";
10
+ import {
11
+ checkScopeWriteAccess,
12
+ hasConfigAccess,
13
+ resolveScopeIds,
14
+ validatePattern,
15
+ validateScope,
16
+ validateType,
17
+ } from "../write-helpers";
18
+
19
+ // Reading the field-level code at a test boundary — KumikoError.details is
20
+ // per-error `unknown`, so one documented cast beats per-assertion narrowing.
21
+ function fieldCode(err: KumikoError | null): string | undefined {
22
+ const d = err?.details as { fields?: ReadonlyArray<{ code: string }> } | undefined;
23
+ return d?.fields?.[0]?.code;
24
+ }
25
+
26
+ describe("hasConfigAccess", () => {
27
+ test('"all" grants every caller, including one with no roles', () => {
28
+ expect(hasConfigAccess(["all"], [])).toBe(true);
29
+ expect(hasConfigAccess(["all"], ["whatever"])).toBe(true);
30
+ });
31
+
32
+ test("grants when a user role intersects the access list", () => {
33
+ expect(hasConfigAccess(["Admin", "Editor"], ["Viewer", "Editor"])).toBe(true);
34
+ });
35
+
36
+ test('denies when no role intersects and "all" is absent', () => {
37
+ expect(hasConfigAccess(["Admin"], ["Viewer"])).toBe(false);
38
+ expect(hasConfigAccess([], ["Admin"])).toBe(false);
39
+ });
40
+ });
41
+
42
+ describe("checkScopeWriteAccess", () => {
43
+ test("non-system scope is always allowed (no level gate)", () => {
44
+ expect(checkScopeWriteAccess(ConfigScopes.tenant, [])).toBeNull();
45
+ expect(checkScopeWriteAccess(ConfigScopes.user, ["Viewer"])).toBeNull();
46
+ });
47
+
48
+ test("system scope allows the machine actor (SYSTEM_ROLE)", () => {
49
+ expect(checkScopeWriteAccess(ConfigScopes.system, [SYSTEM_ROLE])).toBeNull();
50
+ });
51
+
52
+ test("system scope allows SystemAdmin", () => {
53
+ expect(checkScopeWriteAccess(ConfigScopes.system, ["SystemAdmin"])).toBeNull();
54
+ });
55
+
56
+ test("system scope denies a TenantAdmin", () => {
57
+ const err = checkScopeWriteAccess(ConfigScopes.system, ["TenantAdmin"]);
58
+ expect(err?.code).toBe("access_denied");
59
+ expect(err?.i18nKey).toBe("config.errors.systemScopeWriteDenied");
60
+ });
61
+ });
62
+
63
+ describe("validateScope", () => {
64
+ test("a scope at or below the defined level is allowed", () => {
65
+ // defined = user (most specific): system + tenant + user all fit under it.
66
+ expect(validateScope(ConfigScopes.system, ConfigScopes.user, "k")).toBeNull();
67
+ expect(validateScope(ConfigScopes.tenant, ConfigScopes.user, "k")).toBeNull();
68
+ expect(validateScope(ConfigScopes.user, ConfigScopes.user, "k")).toBeNull();
69
+ });
70
+
71
+ test("requesting a more specific scope than defined is rejected", () => {
72
+ // defined = tenant, requested = user (more specific) -> reject.
73
+ const err = validateScope(ConfigScopes.user, ConfigScopes.tenant, "my:key");
74
+ expect(err?.code).toBe("unprocessable");
75
+ expect(err?.i18nKey).toBe("config.errors.invalidScope");
76
+ });
77
+ });
78
+
79
+ describe("resolveScopeIds", () => {
80
+ const tenant = "tenant-9" as TenantId;
81
+
82
+ test("system scope pins SYSTEM_TENANT_ID and drops the user", () => {
83
+ expect(resolveScopeIds(ConfigScopes.system, tenant, "user-1")).toEqual({
84
+ tenantId: SYSTEM_TENANT_ID,
85
+ userId: null,
86
+ });
87
+ });
88
+
89
+ test("tenant scope keeps the tenant, drops the user", () => {
90
+ expect(resolveScopeIds(ConfigScopes.tenant, tenant, "user-1")).toEqual({
91
+ tenantId: tenant,
92
+ userId: null,
93
+ });
94
+ });
95
+
96
+ test("user scope keeps both tenant and user", () => {
97
+ expect(resolveScopeIds(ConfigScopes.user, tenant, "user-1")).toEqual({
98
+ tenantId: tenant,
99
+ userId: "user-1",
100
+ });
101
+ });
102
+ });
103
+
104
+ describe("validateType", () => {
105
+ const numberKey = createTenantConfig("number", {});
106
+ const boolKey = createTenantConfig("boolean", {});
107
+ const textKey = createTenantConfig("text", {});
108
+ const selectKey = createTenantConfig("select", { options: ["a", "b"] });
109
+
110
+ test("accepts a matching primitive for each type", () => {
111
+ expect(validateType(5, numberKey)).toBeNull();
112
+ expect(validateType(true, boolKey)).toBeNull();
113
+ expect(validateType("x", textKey)).toBeNull();
114
+ expect(validateType("a", selectKey)).toBeNull();
115
+ });
116
+
117
+ test("rejects a mismatching primitive with invalid_type", () => {
118
+ const err = validateType("5", numberKey);
119
+ expect(err?.code).toBe("validation_error");
120
+ expect(fieldCode(err)).toBe("invalid_type");
121
+ });
122
+
123
+ test("select rejects a value outside its options with invalid_option", () => {
124
+ const err = validateType("c", selectKey);
125
+ expect(err?.code).toBe("validation_error");
126
+ expect(fieldCode(err)).toBe("invalid_option");
127
+ });
128
+ });
129
+
130
+ describe("validatePattern", () => {
131
+ const textKey = createTenantConfig("text", { pattern: { regex: "^[a-z]+$" } });
132
+
133
+ test("returns null when the value matches the pattern", () => {
134
+ expect(validatePattern("abc", textKey)).toBeNull();
135
+ });
136
+
137
+ test("rejects a non-matching value with invalid_format", () => {
138
+ const err = validatePattern("AB1", textKey);
139
+ expect(err?.code).toBe("validation_error");
140
+ expect(fieldCode(err)).toBe("invalid_format");
141
+ });
142
+
143
+ test("a malformed author regex surfaces as InternalError, not a throw", () => {
144
+ const badKey = createTenantConfig("text", { pattern: { regex: "(" } });
145
+ const err = validatePattern("abc", badKey);
146
+ expect(err?.code).toBe("internal_error");
147
+ });
148
+
149
+ test("non-text keys (no pattern applicable) are skipped", () => {
150
+ expect(validatePattern(5, createTenantConfig("number", {}))).toBeNull();
151
+ });
152
+ });
@@ -87,6 +87,7 @@ export async function collectMissingRequiredConfig(
87
87
  if (keyDef.required !== true) continue;
88
88
  if (!effectiveGate(qualifiedKey)) continue;
89
89
  if (options?.skipAccessFilter !== true && !hasConfigAccess(keyDef.access.read, user.roles)) {
90
+ // skip: key not visible to this user's roles (access-filtered listing)
90
91
  continue;
91
92
  }
92
93
  candidates.set(qualifiedKey, keyDef);
@@ -24,7 +24,6 @@ const OWN_SOURCES: ReadonlySet<ConfigValueSource> = new Set(["user-row", "tenant
24
24
  // flagging a working key as missing would nag tenants to set already-
25
25
  // functioning config. So "nor that it is set" holds for the value queries, not
26
26
  // for the functional readiness rollup. See readiness.query.ts.
27
- // Shared mask for redacted config values across the read handlers (cascade + values).
28
27
  export const MASKED = "••••••";
29
28
 
30
29
  export function mayViewInheritedValue(roles: readonly string[]): boolean {
@@ -105,9 +105,11 @@ beforeEach(async () => {
105
105
  const admin = createTestUser({ roles: ["TenantAdmin"] });
106
106
  const systemAdmin = createTestUser({ roles: ["SystemAdmin"] });
107
107
 
108
+ // Active definitions only — delete soft-deletes (the deterministic stream is
109
+ // kept so a re-define can restore it), so isDeleted rows must not count.
108
110
  async function countDefinitions(tenantId: string, fieldKey: string): Promise<number> {
109
111
  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",
112
+ "SELECT count(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1 AND field_key = $2 AND is_deleted = FALSE",
111
113
  [tenantId, fieldKey],
112
114
  );
113
115
  return (rows as ReadonlyArray<{ n: number }>)[0]?.n ?? 0;
@@ -124,6 +126,22 @@ async function fetchDefinitionRow(
124
126
  return (rows as ReadonlyArray<Record<string, unknown>>)[0];
125
127
  }
126
128
 
129
+ // The persisted customField.set event payload for a (host aggregate, fieldKey),
130
+ // or undefined if none. Used to assert what did / didn't land in kumiko_events.
131
+ async function fetchSetEventPayload(
132
+ aggregateId: string,
133
+ fieldKey: string,
134
+ ): Promise<Record<string, unknown> | undefined> {
135
+ const rows = await asRawClient(stack.db).unsafe(
136
+ "SELECT payload FROM kumiko_events WHERE aggregate_id = $1",
137
+ [aggregateId],
138
+ );
139
+ const payloads = (rows as ReadonlyArray<{ payload: unknown }>).map((r) =>
140
+ typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload,
141
+ ) as Array<Record<string, unknown>>;
142
+ return payloads.find((p) => p?.["fieldKey"] === fieldKey);
143
+ }
144
+
127
145
  async function defineField(entityName: string, fieldKey: string, type = "text") {
128
146
  return stack.http.writeOk(
129
147
  "custom-fields:write:define-tenant-field",
@@ -359,6 +377,182 @@ describe("custom-fields integration — define/delete handler coverage (B1)", ()
359
377
  });
360
378
  });
361
379
 
380
+ describe("custom-fields integration — define resurrection (B1)", () => {
381
+ test("tenant: define → delete → re-define same fieldKey succeeds with the new payload", async () => {
382
+ await defineField("property", "recur", "text");
383
+ expect(await countDefinitions(admin.tenantId, "recur")).toBe(1);
384
+
385
+ await stack.http.writeOk(
386
+ "custom-fields:write:delete-tenant-field",
387
+ { entityName: "property", fieldKey: "recur" },
388
+ admin,
389
+ );
390
+ expect(await countDefinitions(admin.tenantId, "recur")).toBe(0);
391
+
392
+ // Re-defining the same (entity, fieldKey) — deterministic id, the stream was
393
+ // deleted — must succeed (resurrect) AND reflect the new definition payload.
394
+ await stack.http.writeOk(
395
+ "custom-fields:write:define-tenant-field",
396
+ {
397
+ entityName: "property",
398
+ fieldKey: "recur",
399
+ serializedField: { type: "number" },
400
+ required: false,
401
+ searchable: false,
402
+ displayOrder: 0,
403
+ },
404
+ admin,
405
+ );
406
+ expect(await countDefinitions(admin.tenantId, "recur")).toBe(1);
407
+ const row = await fetchDefinitionRow(admin.tenantId, "recur");
408
+ expect(row?.["type"]).toBe("number");
409
+ });
410
+
411
+ test("system: define → delete → re-define same fieldKey succeeds", async () => {
412
+ const def = {
413
+ entityName: "property",
414
+ fieldKey: "vendorRecur",
415
+ serializedField: { type: "text" },
416
+ required: false,
417
+ searchable: false,
418
+ displayOrder: 0,
419
+ };
420
+ await stack.http.writeOk("custom-fields:write:define-system-field", def, systemAdmin);
421
+ await stack.http.writeOk(
422
+ "custom-fields:write:delete-system-field",
423
+ { entityName: "property", fieldKey: "vendorRecur" },
424
+ systemAdmin,
425
+ );
426
+ expect(await countDefinitions(SYSTEM_TENANT_ID, "vendorRecur")).toBe(0);
427
+
428
+ await stack.http.writeOk("custom-fields:write:define-system-field", def, systemAdmin);
429
+ expect(await countDefinitions(SYSTEM_TENANT_ID, "vendorRecur")).toBe(1);
430
+ });
431
+ });
432
+
433
+ describe("custom-fields integration — sensitive value self-projected, kept out of the event log (#2)", () => {
434
+ test("sensitive field: value reaches the projection but NOT kumiko_events", async () => {
435
+ await stack.http.writeOk(
436
+ "custom-fields:write:define-tenant-field",
437
+ {
438
+ entityName: "property",
439
+ fieldKey: "taxId",
440
+ serializedField: { type: "text", sensitive: true },
441
+ required: false,
442
+ searchable: false,
443
+ displayOrder: 0,
444
+ },
445
+ admin,
446
+ );
447
+ const propId = "aaaaaaaa-aaaa-4000-8000-0000000000a1";
448
+ await createProperty(propId, "Sensitive Prop");
449
+ await setCustomField("property", propId, "taxId", "DE-TAX-999");
450
+
451
+ // Read model HAS the value — self-projected directly into the host row.
452
+ const props = await listProperties();
453
+ const row = props.rows.find((r) => r["id"] === propId);
454
+ expect(row?.["taxId"]).toBe("DE-TAX-999");
455
+
456
+ // The immutable event log does NOT carry the value (erasable by design).
457
+ const payload = await fetchSetEventPayload(propId, "taxId");
458
+ expect(payload).toBeDefined();
459
+ expect(payload?.["fieldKey"]).toBe("taxId");
460
+ expect(payload && "value" in payload).toBe(false);
461
+ });
462
+
463
+ test("non-sensitive field: value IS in the event log (control)", async () => {
464
+ const propId = "aaaaaaaa-aaaa-4000-8000-0000000000a2";
465
+ await defineField("property", "publicNote", "text");
466
+ await createProperty(propId, "Public Prop");
467
+ await setCustomField("property", propId, "publicNote", "visible");
468
+
469
+ const payload = await fetchSetEventPayload(propId, "publicNote");
470
+ expect(payload?.["value"]).toBe("visible");
471
+ });
472
+
473
+ test("rebuild replay: re-applying logged events restores non-sensitive, not sensitive", async () => {
474
+ await defineField("property", "publicTag", "text");
475
+ await stack.http.writeOk(
476
+ "custom-fields:write:define-tenant-field",
477
+ {
478
+ entityName: "property",
479
+ fieldKey: "secret",
480
+ serializedField: { type: "text", sensitive: true },
481
+ required: false,
482
+ searchable: false,
483
+ displayOrder: 0,
484
+ },
485
+ admin,
486
+ );
487
+ const propId = "aaaaaaaa-aaaa-4000-8000-0000000000a3";
488
+ await createProperty(propId, "Rebuild Prop");
489
+ await setCustomField("property", propId, "publicTag", "keepme");
490
+ await setCustomField("property", propId, "secret", "DE-TAX-777");
491
+ await stack.eventDispatcher?.runOnce();
492
+
493
+ // Wipe the read model, then replay the logged events through the REAL MSP
494
+ // apply fn — exactly the derivation a rebuild performs — without mutating
495
+ // consumer state (which would pollute the shared stack).
496
+ await asRawClient(stack.db).unsafe(
497
+ "UPDATE read_t1_properties SET custom_fields = '{}'::jsonb WHERE id = $1",
498
+ [propId],
499
+ );
500
+ const mspEntry = [...stack.registry.getAllMultiStreamProjections().entries()].find(([name]) =>
501
+ name.includes("property"),
502
+ );
503
+ expect(mspEntry).toBeDefined();
504
+ const apply = mspEntry?.[1].apply ?? {};
505
+ const events = (await asRawClient(stack.db).unsafe(
506
+ "SELECT type, payload FROM kumiko_events WHERE aggregate_id = $1 ORDER BY id ASC",
507
+ [propId],
508
+ )) as ReadonlyArray<{ type: string; payload: unknown }>;
509
+ // The MSP apply value-type declares a 3rd rebuild-context arg; custom-fields'
510
+ // apply only reads (event, tx), so narrow the call to those two for replay.
511
+ type ReplayApplyFn = (event: Record<string, unknown>, tx: unknown) => Promise<void>;
512
+ for (const e of events) {
513
+ const fn = apply[e.type] as ReplayApplyFn | undefined;
514
+ if (!fn) continue;
515
+ const payload = typeof e.payload === "string" ? JSON.parse(e.payload) : e.payload;
516
+ await fn(
517
+ {
518
+ type: e.type,
519
+ payload,
520
+ aggregateId: propId,
521
+ aggregateType: "property",
522
+ tenantId: admin.tenantId,
523
+ },
524
+ asRawClient(stack.db),
525
+ );
526
+ }
527
+
528
+ const row = (await listProperties()).rows.find((r) => r["id"] === propId);
529
+ // Non-sensitive: restored from its value-bearing event.
530
+ expect(row?.["publicTag"]).toBe("keepme");
531
+ // Sensitive: the logged event carried no value → gone. The accepted, DURABLE
532
+ // rebuild-loss — and why a forget can't be undone by a rebuild.
533
+ expect(row?.["secret"]).toBeUndefined();
534
+ });
535
+
536
+ test("update-tenant-field rejects flipping `sensitive` (would orphan logged PII)", async () => {
537
+ await defineField("property", "maybePii", "text"); // non-sensitive at definition
538
+ const err = await stack.http.writeErr(
539
+ "custom-fields:write:update-tenant-field",
540
+ {
541
+ entityName: "property",
542
+ fieldKey: "maybePii",
543
+ serializedField: { type: "text", sensitive: true }, // attempt the flip
544
+ required: false,
545
+ searchable: false,
546
+ displayOrder: 0,
547
+ },
548
+ admin,
549
+ );
550
+ expect(err.httpStatus).toBe(422);
551
+ expect(err.code).toBe("unprocessable");
552
+ expect(err.details).toMatchObject({ reason: "field_sensitive_immutable" });
553
+ });
554
+ });
555
+
362
556
  describe("custom-fields integration — value validation (Builder-Reuse)", () => {
363
557
  async function setErr(entityId: string, fieldKey: string, value: unknown) {
364
558
  return stack.http.writeErr(
@@ -171,10 +171,7 @@ describe("createCustomFieldsFeature access-options", () => {
171
171
  expect(listAccess(feature)).toEqual(["Admin", "Editor"]);
172
172
  });
173
173
 
174
- // #334/2: valueWriteRoles ohne fieldDefinitionListRoles brach asymmetrisch
175
- // Save offen für App-Rollen, aber der List-Lade-Pfad blieb ["TenantAdmin"] →
176
- // App-User bekamen access_denied, die FormSection lud nie. Die Value-Rollen
177
- // erben jetzt in den List-Default (Union mit dem Default).
174
+ // valueWriteRoles without fieldDefinitionListRoles broke asymmetrically (save open, list closed) value roles now inherit into the list default.
178
175
  test("valueWriteRoles erbt in den List-Default wenn fieldDefinitionListRoles fehlt", () => {
179
176
  const feature = createCustomFieldsFeature({ valueWriteRoles: ["Admin", "Editor"] });
180
177
  const roles = listAccess(feature);
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { fieldWriteAccessDeniedRoles } from "../lib/field-access";
3
+ import type { SerializedFieldShape } from "../lib/parse-serialized-field";
4
+
5
+ function field(write?: ReadonlyArray<string>): SerializedFieldShape {
6
+ return { type: "text", ...(write ? { fieldAccess: { write } } : {}) };
7
+ }
8
+
9
+ describe("fieldWriteAccessDeniedRoles", () => {
10
+ test("allows (null) when the definition is absent", () => {
11
+ expect(fieldWriteAccessDeniedRoles(null, ["Viewer"])).toBeNull();
12
+ });
13
+
14
+ test("allows (null) when no write restriction is declared", () => {
15
+ expect(fieldWriteAccessDeniedRoles(field(), ["Viewer"])).toBeNull();
16
+ expect(fieldWriteAccessDeniedRoles(field([]), ["Viewer"])).toBeNull();
17
+ });
18
+
19
+ test("allows when a user role intersects the required roles", () => {
20
+ expect(
21
+ fieldWriteAccessDeniedRoles(field(["TenantAdmin"]), ["Viewer", "TenantAdmin"]),
22
+ ).toBeNull();
23
+ });
24
+
25
+ test("returns the required roles when the user lacks them", () => {
26
+ expect(fieldWriteAccessDeniedRoles(field(["TenantAdmin"]), ["Viewer"])).toEqual([
27
+ "TenantAdmin",
28
+ ]);
29
+ });
30
+
31
+ test("match is exact — a drifted role name (Admin vs TenantAdmin) denies", () => {
32
+ expect(fieldWriteAccessDeniedRoles(field(["TenantAdmin"]), ["Admin"])).toEqual(["TenantAdmin"]);
33
+ });
34
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isFieldDefinitionRow, parseSerializedField } from "../lib/parse-serialized-field";
3
+
4
+ describe("parseSerializedField", () => {
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 });
8
+ });
9
+
10
+ test("accepts an already-parsed object (jsonb-tolerant driver path)", () => {
11
+ const obj = { type: "select", fieldAccess: { write: ["TenantAdmin"] } };
12
+ expect(parseSerializedField(obj)).toBe(obj);
13
+ });
14
+
15
+ test("returns null for a corrupt JSON string", () => {
16
+ expect(parseSerializedField("{not json")).toBeNull();
17
+ });
18
+
19
+ test("returns null when the shape lacks a string type", () => {
20
+ expect(parseSerializedField('{"sensitive":true}')).toBeNull();
21
+ expect(parseSerializedField({ type: 42 })).toBeNull();
22
+ });
23
+
24
+ test("returns null for non-object inputs", () => {
25
+ expect(parseSerializedField(null)).toBeNull();
26
+ expect(parseSerializedField(undefined)).toBeNull();
27
+ expect(parseSerializedField(7)).toBeNull();
28
+ });
29
+ });
30
+
31
+ describe("isFieldDefinitionRow", () => {
32
+ test("true for a row with a string field_key", () => {
33
+ expect(isFieldDefinitionRow({ field_key: "code", serialized_field: "{}" })).toBe(true);
34
+ });
35
+
36
+ test("false when field_key is missing or not a string", () => {
37
+ expect(isFieldDefinitionRow({ serialized_field: "{}" })).toBe(false);
38
+ expect(isFieldDefinitionRow({ field_key: 1 })).toBe(false);
39
+ });
40
+
41
+ test("false for non-object inputs", () => {
42
+ expect(isFieldDefinitionRow(null)).toBe(false);
43
+ expect(isFieldDefinitionRow("field_key")).toBe(false);
44
+ });
45
+ });
@@ -355,6 +355,23 @@ describe("T1.5c: custom-fields user-data-rights through the real runners", () =>
355
355
  expect(customFields).toBeDefined();
356
356
  expect(customFields).not.toHaveProperty("email");
357
357
  expect(customFields).toMatchObject({ vipFlag: true });
358
+
359
+ // The other half of erasure: the sensitive value was self-projected and the
360
+ // customField.set event was persisted value-less — so PII never entered the
361
+ // immutable log. Without this, the strip above would be undone by a rebuild.
362
+ const eventRows = await asRawClient(stack.db).unsafe(
363
+ "SELECT payload FROM kumiko_events WHERE aggregate_id = $1",
364
+ [propertyId],
365
+ );
366
+ const setPayloads = (eventRows as ReadonlyArray<{ payload: unknown }>).map((r) =>
367
+ typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload,
368
+ ) as Array<Record<string, unknown>>;
369
+ const emailSet = setPayloads.find((p) => p?.["fieldKey"] === "email");
370
+ expect(emailSet).toBeDefined();
371
+ expect(emailSet && "value" in emailSet).toBe(false);
372
+ // Control: the non-sensitive value DID go through the log (normal path).
373
+ const vipSet = setPayloads.find((p) => p?.["fieldKey"] === "vipFlag");
374
+ expect(vipSet?.["value"]).toBe(true);
358
375
  });
359
376
 
360
377
  test("forget delete (no override → strategy delete): host removes the row, strip is a no-op", async () => {
@@ -2,8 +2,10 @@ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
 
4
4
  export async function countTenantFieldDefinitions(db: TenantDb, tenantId: string): Promise<number> {
5
+ // Active definitions only — delete soft-deletes (the deterministic stream is
6
+ // kept so a re-define can restore it), so isDeleted rows must not consume quota.
5
7
  const rowsResult = await asRawClient(db.raw).unsafe(
6
- "SELECT COUNT(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1",
8
+ "SELECT COUNT(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1 AND is_deleted = FALSE",
7
9
  [tenantId],
8
10
  );
9
11
  const rows = rowsResult as ReadonlyArray<Record<string, unknown>>; // @cast-boundary db-row
@@ -36,11 +36,18 @@ import {
36
36
  // columns + events.
37
37
  export const fieldDefinitionEntity = createEntity({
38
38
  table: "read_custom_field_definitions",
39
+ // softDelete is required, NOT cosmetic: the aggregate-id is deterministic
40
+ // (uuidv5(tenantId|entityName|fieldKey)), so deleting a definition leaves a
41
+ // (created+deleted) event stream under that id. A hard delete would force the
42
+ // next define to create() at version 0 onto that stream → version_conflict —
43
+ // a deleted (entity, fieldKey) could never be re-defined. With softDelete the
44
+ // define handlers resurrect via restore()+update() (see define-or-resurrect).
45
+ // NB: `retention.strategy` below is the data-retention purge policy, a
46
+ // SEPARATE knob from this executor flag — it does not drive executor.delete.
47
+ softDelete: true,
39
48
  // B1.5 retention-policy — fieldDefinitions sind tenant-Schema-Metadaten,
40
49
  // keine PII-Daten. Lange Retention für Audit (Compliance kann "wann hat
41
- // Tenant das Feld definiert / geändert / gelöscht" fragen). Strategy
42
- // softDelete: row bleibt als marker, value-cleanup (in B2's MSP) macht
43
- // die eigentliche Anonymisierung wenn customFields PII enthielten.
50
+ // Tenant das Feld definiert / geändert / gelöscht" fragen).
44
51
  //
45
52
  // 10-Jahre keepFor ist konservativer Default; per-Tenant kann via
46
53
  // tenantRetentionOverride für eigene Edge-Cases gesetzt werden
@@ -11,7 +11,10 @@ import { z } from "zod";
11
11
 
12
12
  export const customFieldSetSchema = z.object({
13
13
  fieldKey: z.string().min(1).max(64),
14
- value: z.unknown(),
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.
17
+ value: z.unknown().optional(),
15
18
  });
16
19
  export type CustomFieldSetPayload = z.infer<typeof customFieldSetSchema>;
17
20
 
@@ -176,14 +176,10 @@ export type CustomFieldsFeatureOptions = {
176
176
  * gesetzt, dies aber NICHT, erben die Value-Rollen hier hinein (Union mit
177
177
  * dem Default, damit Admins den List-Zugriff behalten) — sonst lädt die
178
178
  * FormSection für Value-Writer nie (access_denied), während der Save-Pfad
179
- * offen wäre (#334/2, asymmetrischer Bruch). */
179
+ * offen wäre (asymmetrischer Bruch). */
180
180
  readonly fieldDefinitionListRoles?: readonly string[];
181
181
  };
182
182
 
183
- // Der List-Pfad muss jeden abdecken, der Values schreiben darf — sonst lädt die
184
- // FormSection nie. Explizite fieldDefinitionListRoles gewinnen; sonst: gesetzte
185
- // valueWriteRoles erben in den List-Default (Union mit dem Default), ungesetzte
186
- // → reiner Default.
187
183
  export function resolveFieldDefinitionListRoles(
188
184
  opts: Pick<CustomFieldsFeatureOptions, "valueWriteRoles" | "fieldDefinitionListRoles">,
189
185
  ): readonly string[] {
@@ -1,6 +1,6 @@
1
1
  import { SYSTEM_TENANT_ID, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { fieldDefinitionAggregateId } from "../aggregate-id";
3
- import { fieldDefinitionExecutor } from "../executor";
3
+ import { defineOrResurrectFieldDefinition } from "../lib/define-or-resurrect";
4
4
  import { buildFieldDefinitionColumns } from "../lib/field-definition-row";
5
5
  import { type DefineFieldPayload, defineFieldPayloadSchema } from "../schemas";
6
6
 
@@ -33,8 +33,9 @@ export const defineSystemFieldHandler: WriteHandlerDef = {
33
33
  // — the row lives in the system-scope-stream.
34
34
  const systemUser = { ...event.user, tenantId: SYSTEM_TENANT_ID };
35
35
 
36
- return fieldDefinitionExecutor.create(
37
- { id: aggregateId, ...buildFieldDefinitionColumns(payload) },
36
+ return defineOrResurrectFieldDefinition(
37
+ aggregateId,
38
+ buildFieldDefinitionColumns(payload),
38
39
  systemUser,
39
40
  ctx.db,
40
41
  );
@@ -1,7 +1,7 @@
1
1
  import { isSystemTenant, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import { fieldDefinitionAggregateId } from "../aggregate-id";
4
- import { fieldDefinitionExecutor } from "../executor";
4
+ import { defineOrResurrectFieldDefinition } from "../lib/define-or-resurrect";
5
5
  import { buildFieldDefinitionColumns } from "../lib/field-definition-row";
6
6
  import { countTenantFieldDefinitions } from "../lib/quota";
7
7
  import { type DefineFieldPayload, defineFieldPayloadSchema } from "../schemas";
@@ -75,8 +75,9 @@ export function createDefineTenantFieldHandler(
75
75
  payload.fieldKey,
76
76
  );
77
77
 
78
- return fieldDefinitionExecutor.create(
79
- { id: aggregateId, ...buildFieldDefinitionColumns(payload) },
78
+ return defineOrResurrectFieldDefinition(
79
+ aggregateId,
80
+ buildFieldDefinitionColumns(payload),
80
81
  event.user,
81
82
  ctx.db,
82
83
  );