@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/package.json +5 -5
  3. package/src/channel-email/feature.ts +1 -1
  4. package/src/channel-in-app/constants.ts +1 -1
  5. package/src/channel-in-app/feature.ts +1 -1
  6. package/src/channel-push/feature.ts +1 -1
  7. package/src/custom-fields/__tests__/audit-integration.integration.ts +277 -0
  8. package/src/custom-fields/__tests__/custom-fields.integration.ts +261 -0
  9. package/src/custom-fields/__tests__/feature.test.ts +8 -1
  10. package/src/custom-fields/__tests__/field-access.integration.ts +268 -0
  11. package/src/custom-fields/__tests__/quota.integration.ts +162 -0
  12. package/src/custom-fields/__tests__/retention.integration.ts +262 -0
  13. package/src/custom-fields/__tests__/user-data-rights.integration.ts +290 -0
  14. package/src/custom-fields/__tests__/wire-for-entity.test.ts +123 -0
  15. package/src/custom-fields/constants.ts +19 -4
  16. package/src/custom-fields/events.ts +21 -0
  17. package/src/custom-fields/feature.ts +135 -29
  18. package/src/custom-fields/handlers/clear-custom-field.write.ts +57 -0
  19. package/src/custom-fields/handlers/define-tenant-field.write.ts +72 -35
  20. package/src/custom-fields/handlers/delete-system-field.write.ts +15 -1
  21. package/src/custom-fields/handlers/delete-tenant-field.write.ts +16 -1
  22. package/src/custom-fields/handlers/set-custom-field.write.ts +77 -0
  23. package/src/custom-fields/index.ts +17 -2
  24. package/src/custom-fields/lib/field-access.ts +75 -0
  25. package/src/custom-fields/lib/parse-serialized-field.ts +45 -0
  26. package/src/custom-fields/lib/quota.ts +28 -0
  27. package/src/custom-fields/run-retention.ts +215 -0
  28. package/src/custom-fields/schemas.ts +37 -4
  29. package/src/custom-fields/wire-for-entity.ts +162 -0
  30. package/src/custom-fields/wire-user-data-rights.ts +169 -0
  31. package/src/rate-limiting/constants.ts +1 -1
  32. package/src/rate-limiting/feature.ts +1 -1
  33. package/src/renderer-simple/feature.ts +1 -1
  34. package/src/template-resolver/table.ts +3 -1
  35. package/src/tenant/invitation-table.ts +2 -1
  36. package/src/text-content/table.ts +3 -1
  37. package/src/user/schema/user.ts +4 -2
@@ -0,0 +1,162 @@
1
+ // T1.5e — per-tenant fieldDefinition quota.
2
+ //
3
+ // `createCustomFieldsFeature({ fieldDefinitionLimitPerTenant: N })`
4
+ // installs a quota-aware `define-tenant-field` handler. The handler
5
+ // rejects with `unprocessable` + reason `cap_exceeded` when the
6
+ // tenant already has >= N definitions.
7
+
8
+ import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
9
+ import {
10
+ createEntity,
11
+ createEntityExecutor,
12
+ createTextField,
13
+ defineFeature,
14
+ } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
16
+ import {
17
+ createTestUser,
18
+ resetEventStore,
19
+ setupTestStack,
20
+ type TestStack,
21
+ unsafeCreateEntityTable,
22
+ } from "@cosmicdrift/kumiko-framework/stack";
23
+ import { sql } from "drizzle-orm";
24
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
25
+ import { z } from "zod";
26
+ import { fieldDefinitionEntity } from "../entity";
27
+ import { createCustomFieldsFeature } from "../feature";
28
+ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
29
+
30
+ const propertyEntity = createEntity({
31
+ table: "read_t15e_properties",
32
+ fields: {
33
+ name: createTextField({ required: true }),
34
+ customFields: customFieldsField(),
35
+ },
36
+ });
37
+ const propertyTable = buildDrizzleTable("property", propertyEntity);
38
+
39
+ const propertyFeature = defineFeature("property-t15e", (r) => {
40
+ r.entity("property", propertyEntity);
41
+ r.requires("custom-fields");
42
+ wireCustomFieldsFor(r, "property", propertyTable);
43
+
44
+ const { executor } = createEntityExecutor("property", propertyEntity);
45
+ r.writeHandler({
46
+ name: "property:create",
47
+ schema: z.object({ id: z.string(), name: z.string() }),
48
+ access: { roles: ["TenantAdmin"] },
49
+ handler: async (event, ctx) =>
50
+ executor.create(
51
+ { id: event.payload.id, name: event.payload.name, customFields: {} },
52
+ event.user,
53
+ ctx.db,
54
+ ),
55
+ });
56
+ });
57
+
58
+ const QUOTA = 3;
59
+ const customFieldsFeature = createCustomFieldsFeature({ fieldDefinitionLimitPerTenant: QUOTA });
60
+ const admin = createTestUser({ id: 1, roles: ["TenantAdmin"] });
61
+
62
+ let stack: TestStack;
63
+
64
+ beforeAll(async () => {
65
+ stack = await setupTestStack({ features: [customFieldsFeature, propertyFeature] });
66
+ await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
67
+ await unsafeCreateEntityTable(stack.db, propertyEntity);
68
+ await createEventsTable(stack.db);
69
+ });
70
+
71
+ afterAll(async () => {
72
+ await stack.cleanup();
73
+ });
74
+
75
+ beforeEach(async () => {
76
+ await resetEventStore(stack);
77
+ await stack.db.execute(sql`DELETE FROM read_t15e_properties`);
78
+ await stack.db.execute(sql`DELETE FROM read_custom_field_definitions`);
79
+ });
80
+
81
+ async function defineField(fieldKey: string) {
82
+ return stack.http.write(
83
+ "custom-fields:write:define-tenant-field",
84
+ {
85
+ entityName: "property",
86
+ fieldKey,
87
+ serializedField: { type: "text" },
88
+ required: false,
89
+ searchable: false,
90
+ displayOrder: 0,
91
+ },
92
+ admin,
93
+ );
94
+ }
95
+
96
+ describe("T1.5e: per-tenant fieldDefinition quota", () => {
97
+ test(`tenant can define up to ${QUOTA} fields, then the next one is rejected`, async () => {
98
+ for (let i = 1; i <= QUOTA; i++) {
99
+ const res = await defineField(`field${i}`);
100
+ expect(res.status).toBe(200);
101
+ }
102
+
103
+ const overflow = await defineField(`field${QUOTA + 1}`);
104
+ expect(overflow.status).toBe(422);
105
+ const body = (await overflow.json()) as { error: { details: Record<string, unknown> } };
106
+ expect(body.error.details).toMatchObject({
107
+ reason: "cap_exceeded",
108
+ capName: "customFields.fieldDefinition.count",
109
+ limit: QUOTA,
110
+ current: QUOTA,
111
+ });
112
+ });
113
+
114
+ test("quota is enforced per tenant", async () => {
115
+ const otherTenantAdmin = createTestUser({
116
+ id: 99,
117
+ tenantId: "00000000-0000-4000-8000-000000000999",
118
+ roles: ["TenantAdmin"],
119
+ });
120
+
121
+ for (let i = 1; i <= QUOTA; i++) {
122
+ await defineField(`field${i}`);
123
+ }
124
+
125
+ // Other tenant on the same handler — their counter is independent.
126
+ const res = await stack.http.write(
127
+ "custom-fields:write:define-tenant-field",
128
+ {
129
+ entityName: "property",
130
+ fieldKey: "first-on-other-tenant",
131
+ serializedField: { type: "text" },
132
+ required: false,
133
+ searchable: false,
134
+ displayOrder: 0,
135
+ },
136
+ otherTenantAdmin,
137
+ );
138
+ expect(res.status).toBe(200);
139
+ });
140
+
141
+ test("quota counts include all entity-names for the tenant (not per-entity)", async () => {
142
+ await defineField("field-A");
143
+ await defineField("field-B");
144
+ await defineField("field-C");
145
+
146
+ // A different entity-name for the same tenant should still trip the
147
+ // quota — the cap is per-tenant total, not per host entity.
148
+ const res = await stack.http.write(
149
+ "custom-fields:write:define-tenant-field",
150
+ {
151
+ entityName: "different-entity",
152
+ fieldKey: "field-on-different-entity",
153
+ serializedField: { type: "text" },
154
+ required: false,
155
+ searchable: false,
156
+ displayOrder: 0,
157
+ },
158
+ admin,
159
+ );
160
+ expect(res.status).toBe(422);
161
+ });
162
+ });
@@ -0,0 +1,262 @@
1
+ // T1.5d — per-field retention sweep.
2
+ //
3
+ // `runCustomFieldsRetention(opts)` walks the host entity's rows, looks up
4
+ // every fieldDefinition with a `retention` policy, and strips/nulls
5
+ // customField values whose host row's `modified_at` is older than the
6
+ // policy's `keepFor`. Strategy `delete` removes the key; `anonymize`
7
+ // nulls it in place.
8
+ //
9
+ // The reference timestamp is the host row's `modified_at`, not a per-key
10
+ // timestamp — see run-retention.ts header for the rationale.
11
+
12
+ import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
13
+ import {
14
+ createEntity,
15
+ createEntityExecutor,
16
+ createTextField,
17
+ defineFeature,
18
+ } from "@cosmicdrift/kumiko-framework/engine";
19
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
20
+ import {
21
+ createTestUser,
22
+ resetEventStore,
23
+ setupTestStack,
24
+ type TestStack,
25
+ unsafeCreateEntityTable,
26
+ } from "@cosmicdrift/kumiko-framework/stack";
27
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
28
+ import { sql } from "drizzle-orm";
29
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
30
+ import { z } from "zod";
31
+ import { fieldDefinitionEntity } from "../entity";
32
+ import { createCustomFieldsFeature } from "../feature";
33
+ import { runCustomFieldsRetention } from "../run-retention";
34
+ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
35
+
36
+ const propertyEntity = createEntity({
37
+ table: "read_t15d_properties",
38
+ fields: {
39
+ name: createTextField({ required: true }),
40
+ customFields: customFieldsField(),
41
+ },
42
+ });
43
+ const propertyTable = buildDrizzleTable("property", propertyEntity);
44
+
45
+ const propertyFeature = defineFeature("property-t15d", (r) => {
46
+ r.entity("property", propertyEntity);
47
+ r.requires("custom-fields");
48
+ wireCustomFieldsFor(r, "property", propertyTable);
49
+
50
+ const { executor } = createEntityExecutor("property", propertyEntity);
51
+ r.writeHandler({
52
+ name: "property:create",
53
+ schema: z.object({ id: z.string(), name: z.string() }),
54
+ access: { roles: ["TenantAdmin"] },
55
+ handler: async (event, ctx) =>
56
+ executor.create(
57
+ { id: event.payload.id, name: event.payload.name, customFields: {} },
58
+ event.user,
59
+ ctx.db,
60
+ ),
61
+ });
62
+ });
63
+
64
+ const customFieldsFeature = createCustomFieldsFeature();
65
+ const admin = createTestUser({ id: 1, roles: ["TenantAdmin"] });
66
+
67
+ let stack: TestStack;
68
+
69
+ beforeAll(async () => {
70
+ stack = await setupTestStack({
71
+ features: [customFieldsFeature, propertyFeature],
72
+ });
73
+ await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
74
+ await unsafeCreateEntityTable(stack.db, propertyEntity);
75
+ await createEventsTable(stack.db);
76
+ });
77
+
78
+ afterAll(async () => {
79
+ await stack.cleanup();
80
+ });
81
+
82
+ beforeEach(async () => {
83
+ await resetEventStore(stack);
84
+ await stack.db.execute(sql`DELETE FROM read_t15d_properties`);
85
+ await stack.db.execute(sql`DELETE FROM read_custom_field_definitions`);
86
+ });
87
+
88
+ async function defineField(fieldKey: string, serializedField: Record<string, unknown>) {
89
+ return stack.http.writeOk(
90
+ "custom-fields:write:define-tenant-field",
91
+ {
92
+ entityName: "property",
93
+ fieldKey,
94
+ serializedField,
95
+ required: false,
96
+ searchable: false,
97
+ displayOrder: 0,
98
+ },
99
+ admin,
100
+ );
101
+ }
102
+
103
+ async function createProperty(id: string, name: string) {
104
+ return stack.http.writeOk("property-t15d:write:property:create", { id, name }, admin);
105
+ }
106
+
107
+ async function setField(entityId: string, fieldKey: string, value: unknown) {
108
+ return stack.http.writeOk(
109
+ "custom-fields:write:set-custom-field",
110
+ { entityName: "property", entityId, fieldKey, value },
111
+ admin,
112
+ );
113
+ }
114
+
115
+ // Time-travel: backdate the host row's modified_at so the row "looks"
116
+ // older than the retention cutoff. Faster than waiting `keepFor` real
117
+ // time and the cleanest way to drive the cron under test.
118
+ async function backdateRow(id: string, isoOlderThan: string) {
119
+ await stack.db.execute(
120
+ sql`UPDATE read_t15d_properties SET modified_at = ${isoOlderThan}::timestamptz WHERE id = ${id}`,
121
+ );
122
+ }
123
+
124
+ async function readRow(id: string): Promise<Record<string, unknown> | undefined> {
125
+ const rows = await stack.db.execute(
126
+ sql`SELECT id, custom_fields FROM read_t15d_properties WHERE id = ${id}`,
127
+ );
128
+ return (rows as ReadonlyArray<Record<string, unknown>>)[0];
129
+ }
130
+
131
+ const T = getTemporal();
132
+ const NOW = T.Instant.from("2026-05-23T10:00:00Z");
133
+
134
+ describe("T1.5d: per-field retention sweep", () => {
135
+ test("delete-strategy: expired key is stripped from the customFields jsonb", async () => {
136
+ const propertyId = "11111111-1111-4000-8000-000000000001";
137
+ await defineField("temp", {
138
+ type: "text",
139
+ retention: { keepFor: "30d", strategy: "delete" },
140
+ });
141
+ await createProperty(propertyId, "WillExpire");
142
+ await setField(propertyId, "temp", "soon-gone");
143
+ await stack.eventDispatcher?.runOnce();
144
+
145
+ // 31 days ago — past the 30d cutoff.
146
+ await backdateRow(propertyId, "2026-04-22T10:00:00Z");
147
+
148
+ const report = await runCustomFieldsRetention({
149
+ db: stack.db,
150
+ tenantId: admin.tenantId,
151
+ entityName: "property",
152
+ entityTable: propertyTable,
153
+ now: NOW,
154
+ });
155
+
156
+ expect(report.rowsUpdated).toBe(1);
157
+ expect(report.removalsByFieldKey).toEqual({ temp: 1 });
158
+ const row = await readRow(propertyId);
159
+ expect(row?.["custom_fields"]).not.toHaveProperty("temp");
160
+ });
161
+
162
+ test("anonymize-strategy: expired key value is set to null, key stays", async () => {
163
+ const propertyId = "22222222-2222-4000-8000-000000000002";
164
+ await defineField("auditTrail", {
165
+ type: "text",
166
+ retention: { keepFor: "30d", strategy: "anonymize" },
167
+ });
168
+ await createProperty(propertyId, "Anonymize");
169
+ await setField(propertyId, "auditTrail", "user@example.com");
170
+ await stack.eventDispatcher?.runOnce();
171
+
172
+ await backdateRow(propertyId, "2026-04-22T10:00:00Z");
173
+
174
+ await runCustomFieldsRetention({
175
+ db: stack.db,
176
+ tenantId: admin.tenantId,
177
+ entityName: "property",
178
+ entityTable: propertyTable,
179
+ now: NOW,
180
+ });
181
+
182
+ const row = await readRow(propertyId);
183
+ const cf = row?.["custom_fields"] as Record<string, unknown>;
184
+ expect(cf).toHaveProperty("auditTrail");
185
+ expect(cf["auditTrail"]).toBeNull();
186
+ });
187
+
188
+ test("not yet expired: key untouched", async () => {
189
+ const propertyId = "33333333-3333-4000-8000-000000000003";
190
+ await defineField("recent", {
191
+ type: "text",
192
+ retention: { keepFor: "30d", strategy: "delete" },
193
+ });
194
+ await createProperty(propertyId, "StillFresh");
195
+ await setField(propertyId, "recent", "keep-me");
196
+ await stack.eventDispatcher?.runOnce();
197
+
198
+ // 10 days ago — well inside 30d.
199
+ await backdateRow(propertyId, "2026-05-13T10:00:00Z");
200
+
201
+ await runCustomFieldsRetention({
202
+ db: stack.db,
203
+ tenantId: admin.tenantId,
204
+ entityName: "property",
205
+ entityTable: propertyTable,
206
+ now: NOW,
207
+ });
208
+
209
+ const row = await readRow(propertyId);
210
+ expect((row?.["custom_fields"] as Record<string, unknown>)["recent"]).toBe("keep-me");
211
+ });
212
+
213
+ test("field without retention policy: untouched even on ancient rows", async () => {
214
+ const propertyId = "44444444-4444-4000-8000-000000000004";
215
+ await defineField("forever", { type: "text" });
216
+ await createProperty(propertyId, "NoPolicy");
217
+ await setField(propertyId, "forever", "keep-me-always");
218
+ await stack.eventDispatcher?.runOnce();
219
+
220
+ // 5 years ago.
221
+ await backdateRow(propertyId, "2021-05-23T10:00:00Z");
222
+
223
+ const report = await runCustomFieldsRetention({
224
+ db: stack.db,
225
+ tenantId: admin.tenantId,
226
+ entityName: "property",
227
+ entityTable: propertyTable,
228
+ now: NOW,
229
+ });
230
+
231
+ expect(report.rowsUpdated).toBe(0);
232
+ const row = await readRow(propertyId);
233
+ expect((row?.["custom_fields"] as Record<string, unknown>)["forever"]).toBe("keep-me-always");
234
+ });
235
+
236
+ test("mixed: only expired-with-policy keys are stripped, others stay", async () => {
237
+ const propertyId = "55555555-5555-4000-8000-000000000005";
238
+ await defineField("temp", {
239
+ type: "text",
240
+ retention: { keepFor: "30d", strategy: "delete" },
241
+ });
242
+ await defineField("keepThis", { type: "text" });
243
+ await createProperty(propertyId, "Mixed");
244
+ await setField(propertyId, "temp", "should-go");
245
+ await setField(propertyId, "keepThis", "should-stay");
246
+ await stack.eventDispatcher?.runOnce();
247
+
248
+ await backdateRow(propertyId, "2026-04-22T10:00:00Z");
249
+
250
+ await runCustomFieldsRetention({
251
+ db: stack.db,
252
+ tenantId: admin.tenantId,
253
+ entityName: "property",
254
+ entityTable: propertyTable,
255
+ now: NOW,
256
+ });
257
+
258
+ const cf = (await readRow(propertyId))?.["custom_fields"] as Record<string, unknown>;
259
+ expect(cf).not.toHaveProperty("temp");
260
+ expect(cf["keepThis"]).toBe("should-stay");
261
+ });
262
+ });