@cosmicdrift/kumiko-bundled-features 0.12.2 → 0.13.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/CHANGELOG.md +106 -0
- package/package.json +5 -5
- package/src/channel-email/feature.ts +1 -1
- package/src/channel-in-app/constants.ts +1 -1
- package/src/channel-in-app/feature.ts +1 -1
- package/src/channel-push/feature.ts +1 -1
- package/src/custom-fields/__tests__/audit-integration.integration.ts +277 -0
- package/src/custom-fields/__tests__/custom-fields.integration.ts +261 -0
- package/src/custom-fields/__tests__/feature.test.ts +8 -1
- package/src/custom-fields/__tests__/field-access.integration.ts +268 -0
- package/src/custom-fields/__tests__/quota.integration.ts +162 -0
- package/src/custom-fields/__tests__/retention.integration.ts +262 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.ts +290 -0
- package/src/custom-fields/__tests__/wire-for-entity.test.ts +123 -0
- package/src/custom-fields/constants.ts +19 -4
- package/src/custom-fields/events.ts +21 -0
- package/src/custom-fields/feature.ts +135 -29
- package/src/custom-fields/handlers/clear-custom-field.write.ts +57 -0
- package/src/custom-fields/handlers/define-tenant-field.write.ts +72 -35
- package/src/custom-fields/handlers/delete-system-field.write.ts +15 -1
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +16 -1
- package/src/custom-fields/handlers/set-custom-field.write.ts +77 -0
- package/src/custom-fields/index.ts +17 -2
- package/src/custom-fields/lib/field-access.ts +75 -0
- package/src/custom-fields/lib/parse-serialized-field.ts +45 -0
- package/src/custom-fields/lib/quota.ts +28 -0
- package/src/custom-fields/run-retention.ts +215 -0
- package/src/custom-fields/schemas.ts +37 -4
- package/src/custom-fields/wire-for-entity.ts +162 -0
- package/src/custom-fields/wire-user-data-rights.ts +169 -0
- package/src/rate-limiting/constants.ts +1 -1
- package/src/rate-limiting/feature.ts +1 -1
- package/src/renderer-simple/feature.ts +1 -1
- package/src/template-resolver/table.ts +3 -1
- package/src/tenant/invitation-table.ts +2 -1
- package/src/text-content/table.ts +3 -1
- package/src/user/schema/user.ts +4 -2
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// T1.5c — user-data-rights wiring for custom-fields.
|
|
2
|
+
//
|
|
3
|
+
// Verifies the full DSGVO loop for custom-field values on a user-owned
|
|
4
|
+
// host entity:
|
|
5
|
+
//
|
|
6
|
+
// * Export (Art. 15+20): every row owned by the user contributes its
|
|
7
|
+
// customFields jsonb into the user's export bundle under
|
|
8
|
+
// `<entity>.customFields`.
|
|
9
|
+
//
|
|
10
|
+
// * Forget strategy=anonymize (Art. 17 with retention obligation):
|
|
11
|
+
// sensitive customFields keys are stripped from the jsonb; non-
|
|
12
|
+
// sensitive keys stay so co-tenants / co-authors keep useful data.
|
|
13
|
+
//
|
|
14
|
+
// * Forget strategy=delete: no-op — the host entity's own user-data-
|
|
15
|
+
// rights hook handles the row delete, jsonb travels with the row.
|
|
16
|
+
|
|
17
|
+
import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
|
|
18
|
+
import {
|
|
19
|
+
createEntity,
|
|
20
|
+
createEntityExecutor,
|
|
21
|
+
createTextField,
|
|
22
|
+
defineFeature,
|
|
23
|
+
EXT_USER_DATA,
|
|
24
|
+
type UserDataDeleteHook,
|
|
25
|
+
type UserDataExportHook,
|
|
26
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
27
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
28
|
+
import {
|
|
29
|
+
createTestUser,
|
|
30
|
+
resetEventStore,
|
|
31
|
+
setupTestStack,
|
|
32
|
+
type TestStack,
|
|
33
|
+
unsafeCreateEntityTable,
|
|
34
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
35
|
+
import { sql } from "drizzle-orm";
|
|
36
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
37
|
+
import { z } from "zod";
|
|
38
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
39
|
+
import { createDataRetentionFeature } from "../../data-retention";
|
|
40
|
+
import { createSessionsFeature } from "../../sessions";
|
|
41
|
+
import { createUserFeature, userEntity } from "../../user";
|
|
42
|
+
import { createUserDataRightsFeature } from "../../user-data-rights";
|
|
43
|
+
import { fieldDefinitionEntity } from "../entity";
|
|
44
|
+
import { createCustomFieldsFeature } from "../feature";
|
|
45
|
+
import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
|
|
46
|
+
import { wireCustomFieldsUserDataRightsFor } from "../wire-user-data-rights";
|
|
47
|
+
|
|
48
|
+
const propertyEntity = createEntity({
|
|
49
|
+
table: "read_t15c_properties",
|
|
50
|
+
fields: {
|
|
51
|
+
name: createTextField({ required: true }),
|
|
52
|
+
customFields: customFieldsField(),
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
const propertyTable = buildDrizzleTable("property", propertyEntity);
|
|
56
|
+
|
|
57
|
+
// Host entity gets its own EXT_USER_DATA-registration too — that's the
|
|
58
|
+
// canonical setup (host bundle handles row-anonymize/delete, custom-fields
|
|
59
|
+
// adds its strip-sensitive-jsonb layer on top). Both hooks fire in the
|
|
60
|
+
// same cleanup-run.
|
|
61
|
+
const hostExportHook: UserDataExportHook = async (ctx) => {
|
|
62
|
+
const rows = await ctx.db.execute(sql`
|
|
63
|
+
SELECT id, name FROM read_t15c_properties
|
|
64
|
+
WHERE inserted_by_id = ${ctx.userId} AND tenant_id = ${ctx.tenantId}
|
|
65
|
+
`);
|
|
66
|
+
const list = rows as ReadonlyArray<Record<string, unknown>>;
|
|
67
|
+
if (list.length === 0) return null;
|
|
68
|
+
return {
|
|
69
|
+
entity: "property",
|
|
70
|
+
rows: list.map((r) => ({ id: r["id"] as string, name: r["name"] as string })),
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const hostDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
|
|
75
|
+
if (strategy === "delete") {
|
|
76
|
+
await ctx.db.execute(sql`
|
|
77
|
+
DELETE FROM read_t15c_properties
|
|
78
|
+
WHERE inserted_by_id = ${ctx.userId} AND tenant_id = ${ctx.tenantId}
|
|
79
|
+
`);
|
|
80
|
+
} else {
|
|
81
|
+
// anonymize: clear owner, keep row + non-sensitive customFields
|
|
82
|
+
await ctx.db.execute(sql`
|
|
83
|
+
UPDATE read_t15c_properties SET inserted_by_id = NULL
|
|
84
|
+
WHERE inserted_by_id = ${ctx.userId} AND tenant_id = ${ctx.tenantId}
|
|
85
|
+
`);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const propertyFeature = defineFeature("property-t15c", (r) => {
|
|
90
|
+
r.entity("property", propertyEntity);
|
|
91
|
+
r.requires("custom-fields");
|
|
92
|
+
wireCustomFieldsFor(r, "property", propertyTable);
|
|
93
|
+
wireCustomFieldsUserDataRightsFor(r, {
|
|
94
|
+
entityName: "property",
|
|
95
|
+
entityTable: propertyTable,
|
|
96
|
+
userIdColumn: "inserted_by_id",
|
|
97
|
+
});
|
|
98
|
+
r.useExtension(EXT_USER_DATA, "property", {
|
|
99
|
+
export: hostExportHook,
|
|
100
|
+
delete: hostDeleteHook,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const { executor } = createEntityExecutor("property", propertyEntity);
|
|
104
|
+
r.writeHandler({
|
|
105
|
+
name: "property:create",
|
|
106
|
+
schema: z.object({ id: z.string(), name: z.string() }),
|
|
107
|
+
access: { roles: ["TenantAdmin", "TenantMember"] },
|
|
108
|
+
handler: async (event, ctx) =>
|
|
109
|
+
executor.create(
|
|
110
|
+
{ id: event.payload.id, name: event.payload.name, customFields: {} },
|
|
111
|
+
event.user,
|
|
112
|
+
ctx.db,
|
|
113
|
+
),
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const customFieldsFeature = createCustomFieldsFeature();
|
|
118
|
+
const admin = createTestUser({ id: 1, roles: ["TenantAdmin"] });
|
|
119
|
+
|
|
120
|
+
let stack: TestStack;
|
|
121
|
+
|
|
122
|
+
beforeAll(async () => {
|
|
123
|
+
stack = await setupTestStack({
|
|
124
|
+
features: [
|
|
125
|
+
createUserFeature(),
|
|
126
|
+
createSessionsFeature(),
|
|
127
|
+
createDataRetentionFeature(),
|
|
128
|
+
createComplianceProfilesFeature(),
|
|
129
|
+
customFieldsFeature,
|
|
130
|
+
createUserDataRightsFeature(),
|
|
131
|
+
propertyFeature,
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
135
|
+
await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
|
|
136
|
+
await unsafeCreateEntityTable(stack.db, propertyEntity);
|
|
137
|
+
await createEventsTable(stack.db);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
afterAll(async () => {
|
|
141
|
+
await stack.cleanup();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
beforeEach(async () => {
|
|
145
|
+
await resetEventStore(stack);
|
|
146
|
+
await stack.db.execute(sql`DELETE FROM read_t15c_properties`);
|
|
147
|
+
await stack.db.execute(sql`DELETE FROM read_custom_field_definitions`);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
async function defineField(fieldKey: string, serializedField: Record<string, unknown>) {
|
|
151
|
+
return stack.http.writeOk(
|
|
152
|
+
"custom-fields:write:define-tenant-field",
|
|
153
|
+
{
|
|
154
|
+
entityName: "property",
|
|
155
|
+
fieldKey,
|
|
156
|
+
serializedField,
|
|
157
|
+
required: false,
|
|
158
|
+
searchable: false,
|
|
159
|
+
displayOrder: 0,
|
|
160
|
+
},
|
|
161
|
+
admin,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function createProperty(id: string, name: string) {
|
|
166
|
+
return stack.http.writeOk("property-t15c:write:property:create", { id, name }, admin);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function setField(entityId: string, fieldKey: string, value: unknown) {
|
|
170
|
+
return stack.http.writeOk(
|
|
171
|
+
"custom-fields:write:set-custom-field",
|
|
172
|
+
{ entityName: "property", entityId, fieldKey, value },
|
|
173
|
+
admin,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function readRow(id: string): Promise<Record<string, unknown> | undefined> {
|
|
178
|
+
const rows = await stack.db.execute(
|
|
179
|
+
sql`SELECT id, custom_fields FROM read_t15c_properties WHERE id = ${id}`,
|
|
180
|
+
);
|
|
181
|
+
const list = rows as ReadonlyArray<Record<string, unknown>>;
|
|
182
|
+
return list[0];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function callExportHook(userId: string, tenantId: string) {
|
|
186
|
+
const usages = stack.registry.getExtensionUsages(EXT_USER_DATA);
|
|
187
|
+
const customFieldsUsage = usages.find(
|
|
188
|
+
(u) =>
|
|
189
|
+
u.entityName === "property" &&
|
|
190
|
+
(u.options as { export?: unknown })?.export &&
|
|
191
|
+
u.options !== undefined &&
|
|
192
|
+
(u.options as Record<string, unknown>)["export"] !== hostExportHook,
|
|
193
|
+
);
|
|
194
|
+
if (!customFieldsUsage) throw new Error("custom-fields user-data-rights export hook not found");
|
|
195
|
+
const hook = (customFieldsUsage.options as { export: UserDataExportHook }).export;
|
|
196
|
+
return hook({ db: stack.db, tenantId, userId });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function callDeleteHook(userId: string, tenantId: string, strategy: "anonymize" | "delete") {
|
|
200
|
+
const usages = stack.registry.getExtensionUsages(EXT_USER_DATA);
|
|
201
|
+
const customFieldsUsage = usages.find(
|
|
202
|
+
(u) =>
|
|
203
|
+
u.entityName === "property" &&
|
|
204
|
+
(u.options as { delete?: unknown })?.delete &&
|
|
205
|
+
u.options !== undefined &&
|
|
206
|
+
(u.options as Record<string, unknown>)["delete"] !== hostDeleteHook,
|
|
207
|
+
);
|
|
208
|
+
if (!customFieldsUsage) throw new Error("custom-fields user-data-rights delete hook not found");
|
|
209
|
+
const hook = (customFieldsUsage.options as { delete: UserDataDeleteHook }).delete;
|
|
210
|
+
return hook({ db: stack.db, tenantId, userId }, strategy);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
describe("T1.5c: user-data-rights wiring for custom-fields", () => {
|
|
214
|
+
test("export: customFields jsonb travels into the user's export snippet", async () => {
|
|
215
|
+
const propertyId = "11111111-1111-4000-8000-000000000001";
|
|
216
|
+
await defineField("email", { type: "text", sensitive: true });
|
|
217
|
+
await defineField("vipFlag", { type: "boolean" });
|
|
218
|
+
await createProperty(propertyId, "Hofgarten 12");
|
|
219
|
+
await setField(propertyId, "email", "alice@example.com");
|
|
220
|
+
await setField(propertyId, "vipFlag", true);
|
|
221
|
+
await stack.eventDispatcher?.runOnce();
|
|
222
|
+
|
|
223
|
+
const snippet = await callExportHook(String(admin.id), admin.tenantId);
|
|
224
|
+
expect(snippet).not.toBeNull();
|
|
225
|
+
expect(snippet?.entity).toBe("property.customFields");
|
|
226
|
+
expect(snippet?.rows).toHaveLength(1);
|
|
227
|
+
expect(snippet?.rows[0]?.["customFields"]).toMatchObject({
|
|
228
|
+
email: "alice@example.com",
|
|
229
|
+
vipFlag: true,
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("forget anonymize: sensitive keys stripped, non-sensitive keys kept", async () => {
|
|
234
|
+
const propertyId = "22222222-2222-4000-8000-000000000002";
|
|
235
|
+
await defineField("email", { type: "text", sensitive: true });
|
|
236
|
+
await defineField("vipFlag", { type: "boolean" });
|
|
237
|
+
await createProperty(propertyId, "Anonymize-Me");
|
|
238
|
+
await setField(propertyId, "email", "alice@example.com");
|
|
239
|
+
await setField(propertyId, "vipFlag", true);
|
|
240
|
+
await stack.eventDispatcher?.runOnce();
|
|
241
|
+
|
|
242
|
+
await callDeleteHook(String(admin.id), admin.tenantId, "anonymize");
|
|
243
|
+
|
|
244
|
+
const row = await readRow(propertyId);
|
|
245
|
+
const customFields = row?.["custom_fields"] as Record<string, unknown> | undefined;
|
|
246
|
+
expect(customFields).toBeDefined();
|
|
247
|
+
expect(customFields).not.toHaveProperty("email");
|
|
248
|
+
expect(customFields).toMatchObject({ vipFlag: true });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("forget delete: no-op on customFields (host hook removes the row)", async () => {
|
|
252
|
+
const propertyId = "33333333-3333-4000-8000-000000000003";
|
|
253
|
+
await defineField("email", { type: "text", sensitive: true });
|
|
254
|
+
await createProperty(propertyId, "Delete-Me");
|
|
255
|
+
await setField(propertyId, "email", "alice@example.com");
|
|
256
|
+
await stack.eventDispatcher?.runOnce();
|
|
257
|
+
|
|
258
|
+
// call only the custom-fields delete hook (strategy=delete) — verify
|
|
259
|
+
// it doesn't mutate the row (the host hook would handle the actual
|
|
260
|
+
// row delete; we're proving custom-fields stays out of the way).
|
|
261
|
+
await callDeleteHook(String(admin.id), admin.tenantId, "delete");
|
|
262
|
+
|
|
263
|
+
const row = await readRow(propertyId);
|
|
264
|
+
const customFields = row?.["custom_fields"] as Record<string, unknown> | undefined;
|
|
265
|
+
expect(customFields).toMatchObject({ email: "alice@example.com" });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("export: rows without customFields are not included in the snippet", async () => {
|
|
269
|
+
const propertyId = "44444444-4444-4000-8000-000000000004";
|
|
270
|
+
await createProperty(propertyId, "NoCustomFields");
|
|
271
|
+
|
|
272
|
+
const snippet = await callExportHook(String(admin.id), admin.tenantId);
|
|
273
|
+
expect(snippet).toBeNull();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("anonymize without sensitive fields defined is a no-op (everything kept)", async () => {
|
|
277
|
+
const propertyId = "55555555-5555-4000-8000-000000000005";
|
|
278
|
+
await defineField("nonSensitive", { type: "text" });
|
|
279
|
+
await createProperty(propertyId, "AllStay");
|
|
280
|
+
await setField(propertyId, "nonSensitive", "still-here");
|
|
281
|
+
await stack.eventDispatcher?.runOnce();
|
|
282
|
+
|
|
283
|
+
await callDeleteHook(String(admin.id), admin.tenantId, "anonymize");
|
|
284
|
+
|
|
285
|
+
const row = await readRow(propertyId);
|
|
286
|
+
expect((row?.["custom_fields"] as Record<string, unknown>)?.["nonSensitive"]).toBe(
|
|
287
|
+
"still-here",
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { describe, expect, test } from "vitest";
|
|
4
|
+
import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
|
|
5
|
+
|
|
6
|
+
// B2 wireCustomFieldsFor: einziger Aufruf registriert MSP + postQuery-hook +
|
|
7
|
+
// search-payload-extension + useExtension-Marker. Tests pinnen die Surface
|
|
8
|
+
// — Integration via setupTestStack kommt im T1 sprint.
|
|
9
|
+
|
|
10
|
+
const propertyEntity = createEntity({
|
|
11
|
+
table: "read_test_properties",
|
|
12
|
+
fields: {
|
|
13
|
+
name: createTextField({ required: true }),
|
|
14
|
+
customFields: customFieldsField(),
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const propertyTable = buildDrizzleTable("property", propertyEntity);
|
|
19
|
+
|
|
20
|
+
describe("wireCustomFieldsFor", () => {
|
|
21
|
+
test("registers useExtension + MSP + postQuery-entity-hook + search-payload-extension", () => {
|
|
22
|
+
const feature = defineFeature("test-property", (r) => {
|
|
23
|
+
r.entity("property", propertyEntity);
|
|
24
|
+
wireCustomFieldsFor(r, "property", propertyTable);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// 1. useExtension registered
|
|
28
|
+
expect(feature.extensionUsages).toEqual(
|
|
29
|
+
expect.arrayContaining([
|
|
30
|
+
expect.objectContaining({
|
|
31
|
+
extensionName: "customFields",
|
|
32
|
+
entityName: "property",
|
|
33
|
+
}),
|
|
34
|
+
]),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// 2. MSP registered with the right name
|
|
38
|
+
expect(Object.keys(feature.multiStreamProjections)).toEqual(
|
|
39
|
+
expect.arrayContaining(["custom-fields-property-projection"]),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// 3. postQuery entity-hook on "property"
|
|
43
|
+
expect(feature.entityHooks.postQuery["property"]).toHaveLength(1);
|
|
44
|
+
|
|
45
|
+
// 4. search-payload-extension on "property"
|
|
46
|
+
expect(feature.searchPayloadExtensions["property"]).toHaveLength(1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("postQuery-hook flattens row.customFields onto root", async () => {
|
|
50
|
+
const feature = defineFeature("test-property", (r) => {
|
|
51
|
+
r.entity("property", propertyEntity);
|
|
52
|
+
wireCustomFieldsFor(r, "property", propertyTable);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const hook = feature.entityHooks.postQuery["property"]?.[0]?.fn;
|
|
56
|
+
expect(hook).toBeDefined();
|
|
57
|
+
const result = await hook?.(
|
|
58
|
+
{
|
|
59
|
+
entityName: "property",
|
|
60
|
+
rows: [
|
|
61
|
+
{
|
|
62
|
+
id: "p1",
|
|
63
|
+
name: "Hofgarten",
|
|
64
|
+
customFields: { internalNumber: "X-42", vipFlag: true },
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
69
|
+
{} as never,
|
|
70
|
+
);
|
|
71
|
+
expect(result?.rows[0]).toMatchObject({
|
|
72
|
+
id: "p1",
|
|
73
|
+
name: "Hofgarten",
|
|
74
|
+
internalNumber: "X-42",
|
|
75
|
+
vipFlag: true,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("postQuery-hook handles missing/invalid customFields gracefully", async () => {
|
|
80
|
+
const feature = defineFeature("test-property", (r) => {
|
|
81
|
+
r.entity("property", propertyEntity);
|
|
82
|
+
wireCustomFieldsFor(r, "property", propertyTable);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const hook = feature.entityHooks.postQuery["property"]?.[0]?.fn;
|
|
86
|
+
const result = await hook?.(
|
|
87
|
+
{
|
|
88
|
+
entityName: "property",
|
|
89
|
+
rows: [
|
|
90
|
+
{ id: "p1", name: "NoCustomFields" }, // missing customFields
|
|
91
|
+
{ id: "p2", name: "WithEmpty", customFields: {} },
|
|
92
|
+
{ id: "p3", name: "WithNull", customFields: null },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
96
|
+
{} as never,
|
|
97
|
+
);
|
|
98
|
+
expect(result?.rows).toHaveLength(3);
|
|
99
|
+
expect(result?.rows[0]).toMatchObject({ id: "p1", name: "NoCustomFields" });
|
|
100
|
+
expect(result?.rows[1]).toMatchObject({ id: "p2", name: "WithEmpty" });
|
|
101
|
+
expect(result?.rows[2]).toMatchObject({ id: "p3", name: "WithNull" });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("search-payload-extension returns customFields keys flat", async () => {
|
|
105
|
+
const feature = defineFeature("test-property", (r) => {
|
|
106
|
+
r.entity("property", propertyEntity);
|
|
107
|
+
wireCustomFieldsFor(r, "property", propertyTable);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const contributor = feature.searchPayloadExtensions["property"]?.[0]?.fn;
|
|
111
|
+
expect(contributor).toBeDefined();
|
|
112
|
+
const result = await contributor?.({
|
|
113
|
+
entityName: "property",
|
|
114
|
+
entityId: "p1",
|
|
115
|
+
state: {
|
|
116
|
+
id: "p1",
|
|
117
|
+
name: "Hofgarten",
|
|
118
|
+
customFields: { internalNumber: "X-42", vipFlag: true },
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
expect(result).toEqual({ internalNumber: "X-42", vipFlag: true });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -6,10 +6,25 @@
|
|
|
6
6
|
export const CUSTOM_FIELDS_FEATURE_NAME = "custom-fields";
|
|
7
7
|
|
|
8
8
|
// Event-Type-Names (qualified at registration via r.defineEvent — final
|
|
9
|
-
// names are `custom-fields:event:field-definition
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
// names are `custom-fields:event:field-definition-created` etc.).
|
|
10
|
+
// Short-names MUST be in kebab-case (no dots): qualifyEntityName runs toKebab
|
|
11
|
+
// which collapses dots to dashes, so a dotted short-name diverges from the
|
|
12
|
+
// registry key when handlers hand-build the qualified string.
|
|
13
|
+
export const FIELD_DEFINITION_CREATED_EVENT = "field-definition-created";
|
|
14
|
+
export const FIELD_DEFINITION_UPDATED_EVENT = "field-definition-updated";
|
|
15
|
+
export const FIELD_DEFINITION_DELETED_EVENT = "field-definition-deleted";
|
|
16
|
+
|
|
17
|
+
// Custom-field-VALUE events. Live auf host-aggregate stream (ES-Option-B).
|
|
18
|
+
// Short-names werden via r.defineEvent qualified zu `custom-fields:event:
|
|
19
|
+
// custom-field-set` etc. (qualifyEntityName runs toKebab which collapses dots
|
|
20
|
+
// to dashes — so the short-name MUST already be in dash form, otherwise
|
|
21
|
+
// handler-built strings won't match the registry key).
|
|
22
|
+
export const CUSTOM_FIELD_SET_EVENT = "custom-field-set";
|
|
23
|
+
export const CUSTOM_FIELD_CLEARED_EVENT = "custom-field-cleared";
|
|
24
|
+
|
|
25
|
+
// Extension-name für r.useExtension("customFields", "<entity>") — registriert
|
|
26
|
+
// dass eine host-entity Custom-Fields haben darf.
|
|
27
|
+
export const CUSTOM_FIELDS_EXTENSION = "customFields";
|
|
13
28
|
|
|
14
29
|
// Field-type union — identisch zu Stammfeld-Field-Type-System (Spec Z.59-73:
|
|
15
30
|
// `Identisch zu Entity-Feld-Typen`). Builder-Reuse-Promise: was `r.field.X()`
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// Domain-Events für custom-field-VALUES. customField.set + .cleared leben
|
|
4
|
+
// auf der host-aggregate stream (Plan-Doc v2 ES-Option-B: customField-Events
|
|
5
|
+
// auf demselben Stream wie die host-entity's eigene Events).
|
|
6
|
+
//
|
|
7
|
+
// Aggregate-Type im Event ist der host-entity-name (z.B. "property"),
|
|
8
|
+
// aggregate-id ist die host-entity-row-id (z.B. property-uuid). So konsumieren
|
|
9
|
+
// die customFields-MSPs nur die Events ihrer wired-entities (filtered via
|
|
10
|
+
// aggregate-type-match an der jeweiligen consumer-side-MSP-Registration).
|
|
11
|
+
|
|
12
|
+
export const customFieldSetSchema = z.object({
|
|
13
|
+
fieldKey: z.string().min(1).max(64),
|
|
14
|
+
value: z.unknown(),
|
|
15
|
+
});
|
|
16
|
+
export type CustomFieldSetPayload = z.infer<typeof customFieldSetSchema>;
|
|
17
|
+
|
|
18
|
+
export const customFieldClearedSchema = z.object({
|
|
19
|
+
fieldKey: z.string().min(1).max(64),
|
|
20
|
+
});
|
|
21
|
+
export type CustomFieldClearedPayload = z.infer<typeof customFieldClearedSchema>;
|
|
@@ -1,54 +1,160 @@
|
|
|
1
|
-
// custom-fields — Tenant- + System-scoped Custom-Field-Definitions
|
|
1
|
+
// custom-fields — Tenant- + System-scoped Custom-Field-Definitions +
|
|
2
|
+
// generische Custom-Field-VALUE write-handler (host-stream-events).
|
|
2
3
|
//
|
|
3
|
-
// **Was diese Feature liefert (B1, 2026-05-
|
|
4
|
+
// **Was diese Feature liefert (B1 + B2, 2026-05-23):**
|
|
4
5
|
// 1. r.entity("field-definition") — Definition-Storage (event-sourced).
|
|
5
|
-
// 2. define-tenant-field / define-system-field — write-handlers
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// 2. define-tenant-field / define-system-field — RBAC write-handlers für
|
|
7
|
+
// Definition-CRUD.
|
|
8
|
+
// 3. delete-tenant-field / delete-system-field — RBAC write-handlers.
|
|
9
|
+
// 4. set-custom-field / clear-custom-field — write-handlers für VALUES.
|
|
10
|
+
// Emittieren customField.set/.cleared-Events auf host-aggregate-stream.
|
|
11
|
+
// 5. r.defineEvent für customField.set/.cleared + fieldDefinition.deleted.
|
|
12
|
+
// 6. r.extendsRegistrar("customFields") — registriert die extension-name
|
|
13
|
+
// damit consumer via r.useExtension("customFields", "<entity>") opt-in.
|
|
14
|
+
// 7. defineEntityListHandler — read fieldDefinitions (B1-limit: nur tenant-
|
|
15
|
+
// scope; B2-todo: system+tenant UNION als custom query).
|
|
10
16
|
//
|
|
11
|
-
// **
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// -
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
17
|
+
// **Consumer-side-Wiring** (siehe wire-for-entity.ts):
|
|
18
|
+
// Consumer ruft `wireCustomFieldsFor(r, entityName, entityTable)` auf —
|
|
19
|
+
// das registriert pro host-entity: useExtension + MSP (jsonb-projection)
|
|
20
|
+
// + postQuery-hook (flatten) + search-payload-extension.
|
|
21
|
+
//
|
|
22
|
+
// **Host-Entity-Requirement**:
|
|
23
|
+
// Consumer MUSS in der entity-definition eine `customFields`-Spalte als
|
|
24
|
+
// `customFieldsField()` (jsonb) deklarieren.
|
|
25
|
+
//
|
|
26
|
+
// **Exports-Pattern (2026-05-23 refactor)**:
|
|
27
|
+
// `customFieldsFeature.exports.{setEvent,clearedEvent,fieldDefinitionDeletedEvent}`
|
|
28
|
+
// liefern typed EventDef-handles. Handler + wire-for-entity nutzen
|
|
29
|
+
// `<event>.name` als compile-time-literal-typed qualified-string — keine
|
|
30
|
+
// hand-gebauten Template-Literals mehr (T1 hat den toKebab-collapse-drift
|
|
31
|
+
// aufgedeckt, siehe Memory feedback_event_def_exports_pattern).
|
|
20
32
|
//
|
|
21
|
-
// **Out-of-
|
|
22
|
-
// -
|
|
23
|
-
// -
|
|
24
|
-
//
|
|
33
|
+
// **Out-of-B2 (future iterations)**:
|
|
34
|
+
// - Cross-scope-conflict-Detection (Tenant überschreibt system fieldKey)
|
|
35
|
+
// - cap-counter quota-Check beim fieldDefinition-create
|
|
36
|
+
// - user-data-rights anonymization-Wiring für sensitive customFields
|
|
37
|
+
// - Value-Validation gegen fieldDefinition.serializedField
|
|
38
|
+
// - Cross-Scope-Read-UNION (system + tenant fieldDefinitions in einem List)
|
|
25
39
|
|
|
26
40
|
import { defineEntityListHandler, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
27
|
-
import {
|
|
41
|
+
import { z } from "zod";
|
|
42
|
+
import {
|
|
43
|
+
CUSTOM_FIELD_CLEARED_EVENT,
|
|
44
|
+
CUSTOM_FIELD_SET_EVENT,
|
|
45
|
+
CUSTOM_FIELDS_EXTENSION,
|
|
46
|
+
CUSTOM_FIELDS_FEATURE_NAME,
|
|
47
|
+
FIELD_DEFINITION_DELETED_EVENT,
|
|
48
|
+
} from "./constants";
|
|
28
49
|
import { fieldDefinitionEntity } from "./entity";
|
|
50
|
+
import { customFieldClearedSchema, customFieldSetSchema } from "./events";
|
|
51
|
+
import { clearCustomFieldHandler } from "./handlers/clear-custom-field.write";
|
|
29
52
|
import { defineSystemFieldHandler } from "./handlers/define-system-field.write";
|
|
30
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
createDefineTenantFieldHandler,
|
|
55
|
+
defineTenantFieldHandler,
|
|
56
|
+
} from "./handlers/define-tenant-field.write";
|
|
31
57
|
import { deleteSystemFieldHandler } from "./handlers/delete-system-field.write";
|
|
32
58
|
import { deleteTenantFieldHandler } from "./handlers/delete-tenant-field.write";
|
|
59
|
+
import { setCustomFieldHandler } from "./handlers/set-custom-field.write";
|
|
33
60
|
|
|
34
61
|
const tenantAdminAccess = { access: { roles: ["TenantAdmin"] } } as const;
|
|
35
62
|
|
|
36
|
-
|
|
63
|
+
const fieldDefinitionDeletedSchema = z.object({
|
|
64
|
+
entityName: z.string(),
|
|
65
|
+
fieldKey: z.string(),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Singleton feature-definition mit typed exports. Handler + wire-for-entity
|
|
69
|
+
// importieren diesen `customFieldsFeature` und greifen lazy in ihrer
|
|
70
|
+
// runtime-arrow-fn auf `.exports.<event>.name` zu — der module-cycle
|
|
71
|
+
// (feature.ts -> handlers/*.write.ts -> feature.ts) löst sich auf weil
|
|
72
|
+
// kein top-level-access stattfindet.
|
|
73
|
+
export const customFieldsFeature = defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) => {
|
|
74
|
+
r.entity("field-definition", fieldDefinitionEntity);
|
|
75
|
+
|
|
76
|
+
// Event-types — qualified als "custom-fields:event:<short-name>".
|
|
77
|
+
// Returned EventDefs liefern .name als compile-time literal-typed string,
|
|
78
|
+
// den Handler + MSP-keys konsumieren statt Template-Literal-Konstruktion.
|
|
79
|
+
const setEvent = r.defineEvent(CUSTOM_FIELD_SET_EVENT, customFieldSetSchema);
|
|
80
|
+
const clearedEvent = r.defineEvent(CUSTOM_FIELD_CLEARED_EVENT, customFieldClearedSchema);
|
|
81
|
+
const fieldDefinitionDeletedEvent = r.defineEvent(
|
|
82
|
+
FIELD_DEFINITION_DELETED_EVENT,
|
|
83
|
+
fieldDefinitionDeletedSchema,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Extension-Registrar — registriert dass diese Extension existiert.
|
|
87
|
+
// Consumer-side: r.useExtension("customFields", "<entity>") MARKIERT
|
|
88
|
+
// opt-in, aber wired NICHTS automatisch. Consumer MUSS zusätzlich
|
|
89
|
+
// `wireCustomFieldsFor(r, entity, table)` aufrufen damit MSP +
|
|
90
|
+
// postQuery-hook + search-extension tatsächlich registriert werden.
|
|
91
|
+
// Empty-options-Pattern (`{}`) ist absichtlich — boot-time-onRegister-
|
|
92
|
+
// wiring würde Closure über Drizzle-Table benötigen, die der Consumer
|
|
93
|
+
// bei extendsRegistrar-Registration nicht kennt. Daher consumer-side
|
|
94
|
+
// explicit-wiring statt magic-auto-wiring.
|
|
95
|
+
r.extendsRegistrar(CUSTOM_FIELDS_EXTENSION, {});
|
|
96
|
+
|
|
97
|
+
// Definition-CRUD handlers (B1).
|
|
98
|
+
r.writeHandler(defineTenantFieldHandler);
|
|
99
|
+
r.writeHandler(defineSystemFieldHandler);
|
|
100
|
+
r.writeHandler(deleteTenantFieldHandler);
|
|
101
|
+
r.writeHandler(deleteSystemFieldHandler);
|
|
102
|
+
|
|
103
|
+
// Value-write handlers (B2). Emittieren events auf host-aggregate-stream.
|
|
104
|
+
r.writeHandler(setCustomFieldHandler);
|
|
105
|
+
r.writeHandler(clearCustomFieldHandler);
|
|
106
|
+
|
|
107
|
+
// List-Query — tenant-scoped (B1-limit).
|
|
108
|
+
r.queryHandler(
|
|
109
|
+
defineEntityListHandler("field-definition", fieldDefinitionEntity, tenantAdminAccess),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return { setEvent, clearedEvent, fieldDefinitionDeletedEvent };
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Backwards-compat-wrapper. Bestehende Caller (z.B. integration-tests,
|
|
116
|
+
// host-apps) nutzen weiterhin `createCustomFieldsFeature()`. Returnt den
|
|
117
|
+
// module-level-Singleton — kein neuer build pro Aufruf, was für consumer
|
|
118
|
+
// nicht erkennbar ist (read-only inspection).
|
|
119
|
+
//
|
|
120
|
+
// **T1.5e options**: when `fieldDefinitionLimitPerTenant` is set, the
|
|
121
|
+
// returned feature carries a fresh `define-tenant-field` handler with the
|
|
122
|
+
// quota baked in. Callers that don't pass options get the singleton — no
|
|
123
|
+
// change in behavior.
|
|
124
|
+
export function createCustomFieldsFeature(
|
|
125
|
+
opts: { readonly fieldDefinitionLimitPerTenant?: number } = {},
|
|
126
|
+
): typeof customFieldsFeature {
|
|
127
|
+
if (opts.fieldDefinitionLimitPerTenant === undefined) {
|
|
128
|
+
return customFieldsFeature;
|
|
129
|
+
}
|
|
37
130
|
return defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) => {
|
|
38
131
|
r.entity("field-definition", fieldDefinitionEntity);
|
|
39
132
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
r.
|
|
133
|
+
const setEvent = r.defineEvent(CUSTOM_FIELD_SET_EVENT, customFieldSetSchema);
|
|
134
|
+
const clearedEvent = r.defineEvent(CUSTOM_FIELD_CLEARED_EVENT, customFieldClearedSchema);
|
|
135
|
+
const fieldDefinitionDeletedEvent = r.defineEvent(
|
|
136
|
+
FIELD_DEFINITION_DELETED_EVENT,
|
|
137
|
+
fieldDefinitionDeletedSchema,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
r.extendsRegistrar(CUSTOM_FIELDS_EXTENSION, {});
|
|
141
|
+
|
|
142
|
+
r.writeHandler(
|
|
143
|
+
createDefineTenantFieldHandler({
|
|
144
|
+
fieldDefinitionLimitPerTenant: opts.fieldDefinitionLimitPerTenant,
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
43
147
|
r.writeHandler(defineSystemFieldHandler);
|
|
44
148
|
r.writeHandler(deleteTenantFieldHandler);
|
|
45
149
|
r.writeHandler(deleteSystemFieldHandler);
|
|
46
150
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
151
|
+
r.writeHandler(setCustomFieldHandler);
|
|
152
|
+
r.writeHandler(clearCustomFieldHandler);
|
|
153
|
+
|
|
50
154
|
r.queryHandler(
|
|
51
155
|
defineEntityListHandler("field-definition", fieldDefinitionEntity, tenantAdminAccess),
|
|
52
156
|
);
|
|
157
|
+
|
|
158
|
+
return { setEvent, clearedEvent, fieldDefinitionDeletedEvent };
|
|
53
159
|
});
|
|
54
160
|
}
|