@cosmicdrift/kumiko-bundled-features 0.12.2 → 0.14.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 +115 -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,215 @@
1
+ // T1.5d — per-field retention sweep for the customFields jsonb.
2
+ //
3
+ // Iterates one host entity's rows, looks up every fieldDefinition with a
4
+ // `retention` policy, and strips/nulls customField values whose host-row
5
+ // `modified_at` is older than the policy's `keepFor`.
6
+ //
7
+ // Typically invoked by a daily cron in the consumer app — alongside (or
8
+ // inside) the data-retention bundle's own cleanup job. We don't auto-
9
+ // register a cron here because the consumer chooses the schedule, and
10
+ // because some apps want to sweep multiple host entities in one run.
11
+ //
12
+ // Caveat: the reference timestamp is the *host row's* `modified_at`, not
13
+ // a per-customField timestamp. A row's customField hasn't been touched
14
+ // in `keepFor` only when the entire row hasn't been touched in `keepFor`
15
+ // — for value-level granularity, future work needs a value-timestamp
16
+ // jsonb shape, which would be a breaking schema change.
17
+
18
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
19
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
20
+ import { getTableName, sql } from "drizzle-orm";
21
+ import type { PgTable } from "drizzle-orm/pg-core";
22
+ import { parseSerializedField } from "./lib/parse-serialized-field";
23
+
24
+ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
25
+
26
+ // Lifted from data-retention/keep-for.ts because the helper isn't re-exported
27
+ // from the bundle's public index. Same parser semantics: "/^\\d+[hdwmy]$/",
28
+ // month = 30d, year = 365d, hour as exact unit. If data-retention exports it
29
+ // in a future minor we can switch the import back.
30
+ const KEEP_FOR_PATTERN = /^(\d+)([hdwmy])$/;
31
+ const UNIT_TO_DAYS: Record<string, number> = { d: 1, w: 7, m: 30, y: 365 };
32
+
33
+ function isPastCutoff(args: {
34
+ readonly referenceTimestamp: Instant;
35
+ readonly keepFor: string;
36
+ readonly now: Instant;
37
+ }): boolean {
38
+ const match = args.keepFor.match(KEEP_FOR_PATTERN);
39
+ if (!match) return false;
40
+ const amount = Number.parseInt(match[1] ?? "0", 10);
41
+ const unit = match[2] ?? "";
42
+ const hours = unit === "h" ? amount : amount * (UNIT_TO_DAYS[unit] ?? 0) * 24;
43
+ const cutoff = args.now.subtract({ hours });
44
+ return getTemporal().Instant.compare(args.referenceTimestamp, cutoff) < 0;
45
+ }
46
+
47
+ export interface RunCustomFieldsRetentionOptions {
48
+ readonly db: DbRunner;
49
+ readonly tenantId: string;
50
+ readonly entityName: string;
51
+ readonly entityTable: PgTable;
52
+ /** Current time, injected for time-travel-tests. */
53
+ readonly now: Instant;
54
+ }
55
+
56
+ export interface CustomFieldsRetentionReport {
57
+ /** How many host rows were scanned (any customFields content). */
58
+ readonly rowsScanned: number;
59
+ /** How many host rows were updated because at least one key expired. */
60
+ readonly rowsUpdated: number;
61
+ /** Per-fieldKey count of expired-and-removed values. */
62
+ readonly removalsByFieldKey: Record<string, number>;
63
+ }
64
+
65
+ interface RetentionPolicy {
66
+ readonly keepFor: string;
67
+ readonly strategy: "delete" | "anonymize";
68
+ }
69
+
70
+ export async function runCustomFieldsRetention(
71
+ opts: RunCustomFieldsRetentionOptions,
72
+ ): Promise<CustomFieldsRetentionReport> {
73
+ const policies = await loadRetentionPolicies(opts.db, opts.tenantId, opts.entityName);
74
+ if (policies.size === 0) {
75
+ return { rowsScanned: 0, rowsUpdated: 0, removalsByFieldKey: {} };
76
+ }
77
+
78
+ const tableName = sql.identifier(getTableName(opts.entityTable));
79
+ const rowsResult = await opts.db.execute(sql`
80
+ SELECT id, modified_at, custom_fields
81
+ FROM ${tableName}
82
+ WHERE tenant_id = ${opts.tenantId} AND custom_fields IS NOT NULL
83
+ `);
84
+
85
+ const removalsByFieldKey: Record<string, number> = {};
86
+ let rowsUpdated = 0;
87
+ let rowsScanned = 0;
88
+
89
+ const rows: ReadonlyArray<unknown> = Array.isArray(rowsResult) ? rowsResult : [];
90
+ for (const raw of rows) {
91
+ rowsScanned++;
92
+ const row = asHostRow(raw);
93
+ // skip: see asHostRow rationale — defense in depth for driver shape drift.
94
+ if (!row) continue;
95
+ if (Object.keys(row.customFields).length === 0) continue;
96
+
97
+ const modifiedAt = parseInstant(row.modifiedAt);
98
+ // skip: rows without a parseable modified_at can't be aged against any
99
+ // cutoff, leave them untouched.
100
+ if (!modifiedAt) continue;
101
+
102
+ const removals: Array<{ key: string; strategy: "delete" | "anonymize" }> = [];
103
+ for (const [fieldKey, policy] of policies) {
104
+ if (!(fieldKey in row.customFields)) continue;
105
+ const expired = isPastCutoff({
106
+ referenceTimestamp: modifiedAt,
107
+ keepFor: policy.keepFor,
108
+ now: opts.now,
109
+ });
110
+ if (expired) {
111
+ removals.push({ key: fieldKey, strategy: policy.strategy });
112
+ }
113
+ }
114
+
115
+ // skip: nothing on this row aged out — no UPDATE needed.
116
+ if (removals.length === 0) continue;
117
+
118
+ const mutated: Record<string, unknown> = { ...row.customFields };
119
+ for (const { key, strategy } of removals) {
120
+ if (strategy === "delete") {
121
+ delete mutated[key];
122
+ } else {
123
+ mutated[key] = null;
124
+ }
125
+ removalsByFieldKey[key] = (removalsByFieldKey[key] ?? 0) + 1;
126
+ }
127
+
128
+ await opts.db.execute(sql`
129
+ UPDATE ${tableName}
130
+ SET custom_fields = ${JSON.stringify(mutated)}::jsonb
131
+ WHERE id = ${row.id}
132
+ `);
133
+ rowsUpdated++;
134
+ }
135
+
136
+ return { rowsScanned, rowsUpdated, removalsByFieldKey };
137
+ }
138
+
139
+ interface HostRow {
140
+ readonly id: string;
141
+ readonly modifiedAt: unknown;
142
+ readonly customFields: Record<string, unknown>;
143
+ }
144
+
145
+ function asHostRow(value: unknown): HostRow | null {
146
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
147
+ if (!("id" in value) || typeof value.id !== "string") return null;
148
+ if (!("custom_fields" in value)) return null;
149
+ const cf = value.custom_fields;
150
+ if (!cf || typeof cf !== "object" || Array.isArray(cf)) return null;
151
+ return {
152
+ id: value.id,
153
+ modifiedAt: "modified_at" in value ? value.modified_at : null,
154
+ customFields: Object.fromEntries(Object.entries(cf)),
155
+ };
156
+ }
157
+
158
+ interface FieldDefinitionRow {
159
+ readonly field_key: string;
160
+ readonly serialized_field: unknown;
161
+ }
162
+
163
+ function isFieldDefinitionRow(value: unknown): value is FieldDefinitionRow {
164
+ if (!value || typeof value !== "object") return false;
165
+ if (!("field_key" in value)) return false;
166
+ return typeof value.field_key === "string";
167
+ }
168
+
169
+ async function loadRetentionPolicies(
170
+ db: DbRunner,
171
+ tenantId: string,
172
+ entityName: string,
173
+ ): Promise<Map<string, RetentionPolicy>> {
174
+ const rowsResult = await db.execute(sql`
175
+ SELECT field_key, serialized_field
176
+ FROM read_custom_field_definitions
177
+ WHERE entity_name = ${entityName} AND tenant_id = ${tenantId}
178
+ `);
179
+ const rows: ReadonlyArray<unknown> = Array.isArray(rowsResult) ? rowsResult : [];
180
+ const out = new Map<string, RetentionPolicy>();
181
+ for (const raw of rows) {
182
+ // skip: see asHostRow rationale.
183
+ if (!isFieldDefinitionRow(raw)) continue;
184
+ const parsed = parseSerializedField(raw.serialized_field);
185
+ if (parsed?.retention) {
186
+ out.set(raw.field_key, parsed.retention);
187
+ }
188
+ }
189
+ return out;
190
+ }
191
+
192
+ interface InstantLike {
193
+ readonly epochMilliseconds: number;
194
+ }
195
+
196
+ function isInstantLike(value: unknown): value is InstantLike {
197
+ if (!value || typeof value !== "object") return false;
198
+ if (!("epochMilliseconds" in value)) return false;
199
+ return typeof value.epochMilliseconds === "number";
200
+ }
201
+
202
+ function parseInstant(value: unknown): Instant | null {
203
+ if (value == null) return null;
204
+ const T = getTemporal();
205
+ try {
206
+ if (typeof value === "string") return T.Instant.from(value);
207
+ // `Number(date)` on a Date instance returns epoch-ms — matches getTime()
208
+ // without tripping the no-date-api guard.
209
+ if (value instanceof Date) return T.Instant.fromEpochMilliseconds(Number(value));
210
+ if (isInstantLike(value)) return T.Instant.fromEpochMilliseconds(value.epochMilliseconds);
211
+ return null;
212
+ } catch {
213
+ return null;
214
+ }
215
+ }
@@ -4,6 +4,21 @@ import { SUPPORTED_FIELD_TYPES } from "./constants";
4
4
  // Field-Type-Validator — pinnt valid type-Werte für fieldDefinition.
5
5
  const fieldTypeSchema = z.enum(SUPPORTED_FIELD_TYPES);
6
6
 
7
+ // Per-field access-control. When set, `set/clear-custom-field` handlers
8
+ // require the calling user to hold at least one of the listed roles —
9
+ // in addition to the handler-level RBAC. Absent or empty `write` means
10
+ // the handler-level RBAC is the only gate.
11
+ //
12
+ // `read` is reserved for the postQuery-flatten pipeline (T1.5c+); not
13
+ // enforced in T1.5b.
14
+ export const customFieldAccessSchema = z
15
+ .object({
16
+ read: z.array(z.string()).optional(),
17
+ write: z.array(z.string()).optional(),
18
+ })
19
+ .optional();
20
+ export type CustomFieldAccess = z.infer<typeof customFieldAccessSchema>;
21
+
7
22
  // Serialized-field-jsonb — was als `serializedField`-Spalte gespeichert wird.
8
23
  // In v1 noch nicht strict-typed pro field-type (das kommt in B2 wenn die
9
24
  // Stammfeld-Builder-Schemas exposed sind). Hier nur die Struktur-Garantie.
@@ -18,13 +33,31 @@ const fieldTypeSchema = z.enum(SUPPORTED_FIELD_TYPES);
18
33
  // money: { type: "money", required: false, currency: "EUR" }
19
34
  // embedded: { type: "embedded", required: false, schema: { ... } }
20
35
  //
21
- // In B1 akzeptieren wir alle Variants als Generic-jsonb-Payload mit nur dem
22
- // `type`-Pflichtfeld validiert. B2 wird die volle discriminated-union mit
23
- // per-type-options anschließen sobald die Stammfeld-Field-Builder-Schemas
24
- // exportiert sind.
36
+ // `fieldAccess` (T1.5b) and `sensitive` (T1.5c) are the structured keys
37
+ // recognised by the handlers / user-data-rights wiring. Everything else
38
+ // stays loose pending B2's per-type discriminated-union.
25
39
  const serializedFieldSchema = z
26
40
  .looseObject({
27
41
  type: fieldTypeSchema,
42
+ 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
+ // `retention` (T1.5d): per-field expiry. The retention-cron strips
49
+ // values whose host-row `modified_at` is older than `keepFor`. Strategy
50
+ // `delete` removes the key from the customFields jsonb; `anonymize`
51
+ // sets it to `null` (key stays, value gone — preserves the schema
52
+ // shape for downstream consumers).
53
+ retention: z
54
+ .object({
55
+ keepFor: z
56
+ .string()
57
+ .regex(/^\d+[hdwmy]$/, "keepFor must match /^\\d+[hdwmy]$/ (e.g. '30d', '10y')"),
58
+ strategy: z.enum(["delete", "anonymize"]),
59
+ })
60
+ .optional(),
28
61
  })
29
62
  .refine((v) => typeof v["type"] === "string", "serializedField must have a string `type`");
30
63
 
@@ -0,0 +1,162 @@
1
+ import {
2
+ createJsonbField,
3
+ type FeatureRegistrar,
4
+ type JsonbFieldDef,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import type { AnyColumn } from "drizzle-orm";
7
+ import { eq, sql } from "drizzle-orm";
8
+ import type { PgTable } from "drizzle-orm/pg-core";
9
+ import { CUSTOM_FIELDS_EXTENSION } from "./constants";
10
+ import type { CustomFieldClearedPayload, CustomFieldSetPayload } from "./events";
11
+ import { customFieldsFeature } from "./feature";
12
+
13
+ // Helper für entity-definitions — fügt eine `customFields jsonb`-Spalte
14
+ // hinzu. Consumer:
15
+ //
16
+ // const propertyEntity = createEntity({
17
+ // fields: {
18
+ // name: createTextField({ required: true }),
19
+ // customFields: customFieldsField(),
20
+ // },
21
+ // });
22
+ //
23
+ // Spec-Promise: customFields verhält sich wie Stammfelder. Default `{}`,
24
+ // NOT NULL — analog zu embedded-Spalten.
25
+ export function customFieldsField(): JsonbFieldDef {
26
+ return createJsonbField();
27
+ }
28
+
29
+ // Vollständige integration der custom-fields-Bundle für eine spezifische
30
+ // host-entity. Eine einzige Aufruf-Stelle pro consumer registriert ALLE
31
+ // wiring-Aspekte: extension-tracking, MSP für value-projection, postQuery-
32
+ // hook für API-flatten, search-payload-extension für indexable customFields.
33
+ //
34
+ // Consumer-side:
35
+ //
36
+ // defineFeature("property-mgmt", (r) => {
37
+ // r.entity("property", propertyEntity); // muss customFieldsField() haben
38
+ // r.requires(CUSTOM_FIELDS_FEATURE_NAME);
39
+ // wireCustomFieldsFor(r, "property", propertyTable);
40
+ // });
41
+ //
42
+ // Der `entityTable`-Parameter ist die Drizzle-Table-Instance (typically
43
+ // `buildDrizzleTable(name, entity)`-Output). Die Closure über `entityTable`
44
+ // erspart der MSP-apply-fn einen runtime-table-lookup über die Registry.
45
+ //
46
+ // **Was registriert wird**:
47
+ //
48
+ // 1. r.useExtension("customFields", entityName) — opt-in marker,
49
+ // ermöglicht boot-validation und usage-tracking via Registry.
50
+ //
51
+ // 2. r.multiStreamProjection — consumes customField.set/.cleared events
52
+ // die customer's set-custom-field / clear-custom-field write-handlers
53
+ // emittiert haben. Updated entityTable.customFields jsonb über
54
+ // jsonb_set / jsonb-key-removal.
55
+ //
56
+ // 3. r.entityHook("postQuery", entity, flatten-fn) — bei JEDEM Read auf
57
+ // diese entity wird `row.customFields` jsonb auf root-level expanded
58
+ // damit die API-response wie Stammfelder aussieht.
59
+ //
60
+ // 4. r.searchPayloadExtension(entity, contributor) — searchable
61
+ // customFields-keys werden flach ins Meilisearch-Index-Doc beigetragen
62
+ // (F3-wiring).
63
+ //
64
+ // 5. fieldDefinition.deleted-Event-Handler im selben MSP — bei delete
65
+ // einer fieldDefinition werden orphan values aus allen entity-rows
66
+ // entfernt (key-removal pro fieldKey).
67
+ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
68
+ r: TReg,
69
+ entityName: string,
70
+ entityTable: PgTable,
71
+ ): void {
72
+ // biome-ignore lint/correctness/useHookAtTopLevel: r.useExtension is a registrar-API method, not a React hook — false positive on the "use"-prefix heuristic.
73
+ r.useExtension(CUSTOM_FIELDS_EXTENSION, entityName);
74
+
75
+ // Qualified event-type-names — sourced from typed EventDef.name handles
76
+ // (compile-time literal-typed, no Template-Literal-Drift à la toKebab-
77
+ // collapse-bug die T1 aufgedeckt hat).
78
+ const setEventType = customFieldsFeature.exports.setEvent.name;
79
+ const clearedEventType = customFieldsFeature.exports.clearedEvent.name;
80
+ const fieldDefDeletedType = customFieldsFeature.exports.fieldDefinitionDeletedEvent.name;
81
+
82
+ r.multiStreamProjection({
83
+ name: `custom-fields-${entityName}-projection`,
84
+ apply: {
85
+ [setEventType]: async (event, tx) => {
86
+ // skip: MSP feuert für ALLE aggregate-types die customField.set
87
+ // emittieren — wir wollen nur die unserer wired host-entity.
88
+ // Andere consumers haben eigene MSPs für ihre Entities.
89
+ if (event.aggregateType !== entityName) return;
90
+ const payload = event.payload as CustomFieldSetPayload; // @cast-boundary engine-payload
91
+
92
+ // jsonb_set: setze key auf value. Wenn key noch nicht existiert →
93
+ // wird angelegt (create_missing=true ist default). value muss als
94
+ // jsonb-literal kommen — Drizzle sql-template stringifiziert für uns.
95
+ const idCol = (entityTable as unknown as Record<string, AnyColumn>)["id"] as AnyColumn; // @cast-boundary db-row
96
+ await tx
97
+ .update(entityTable)
98
+ .set({
99
+ customFields: sql`jsonb_set(${sql.identifier("custom_fields")}, ${sql.raw(`'{${payload.fieldKey.replace(/'/g, "''")}}'`)}, ${JSON.stringify(payload.value)}::jsonb, true)`,
100
+ })
101
+ .where(eq(idCol, event.aggregateId));
102
+ },
103
+ [clearedEventType]: async (event, tx) => {
104
+ // skip: MSP feuert für alle aggregate-types — nur unsere host-entity
105
+ // verarbeiten.
106
+ if (event.aggregateType !== entityName) return;
107
+ const payload = event.payload as CustomFieldClearedPayload; // @cast-boundary engine-payload
108
+
109
+ // jsonb minus operator (`-`) entfernt key aus jsonb-object.
110
+ const idCol = (entityTable as unknown as Record<string, AnyColumn>)["id"] as AnyColumn; // @cast-boundary db-row
111
+ await tx
112
+ .update(entityTable)
113
+ .set({
114
+ customFields: sql`${sql.identifier("custom_fields")} - ${payload.fieldKey}`,
115
+ })
116
+ .where(eq(idCol, event.aggregateId));
117
+ },
118
+ [fieldDefDeletedType]: async (event, tx) => {
119
+ // fieldDefinition.deleted fires nur einmal pro fieldDef-delete
120
+ // (NICHT per-entity). Wir entfernen den key aus ALLEN rows der host-
121
+ // entity falls die deleted-fieldDef für diese entity galt.
122
+ const payload = event.payload as { entityName: string; fieldKey: string }; // @cast-boundary engine-payload
123
+ // skip: fieldDefinition.deleted feuert für ALLE fieldDefs cross-entity;
124
+ // nur wenn die deleted-fieldDef diese host-entity betraf, cleanen wir
125
+ // ihre Rows.
126
+ if (payload.entityName !== entityName) return;
127
+
128
+ await tx.update(entityTable).set({
129
+ customFields: sql`${sql.identifier("custom_fields")} - ${payload.fieldKey}`,
130
+ });
131
+ },
132
+ },
133
+ });
134
+
135
+ // postQuery-hook: flatten row.customFields jsonb auf root-level der
136
+ // API-response. Spec-Promise Z.4 "indistinguishable von Stammfeldern".
137
+ r.entityHook("postQuery", entityName, async ({ rows }) => ({
138
+ rows: rows.map((row) => {
139
+ const customFields = row["customFields"];
140
+ if (customFields && typeof customFields === "object" && !Array.isArray(customFields)) {
141
+ return {
142
+ ...row,
143
+ ...(customFields as Record<string, unknown>), // @cast-boundary db-row jsonb runtime-untyped
144
+ };
145
+ }
146
+ return row;
147
+ }),
148
+ }));
149
+
150
+ // Search-Payload-Extension: customFields-keys flach ins Index-Doc.
151
+ // Anders als postQuery-hook (der ALLE keys merged) trägt der Search-
152
+ // Contributor nur die als searchable=true definierten fields bei. v1
153
+ // ist conservatively: ALLES contribuieren — B2-follow-up filtert
154
+ // per fieldDefinition.searchable-flag.
155
+ r.searchPayloadExtension(entityName, ({ state }) => {
156
+ const customFields = state["customFields"];
157
+ if (customFields && typeof customFields === "object" && !Array.isArray(customFields)) {
158
+ return customFields as Record<string, unknown>; // @cast-boundary db-row jsonb runtime-untyped
159
+ }
160
+ return {};
161
+ });
162
+ }
@@ -0,0 +1,169 @@
1
+ // T1.5c — user-data-rights wiring for custom-fields.
2
+ //
3
+ // A consumer that wires `customFields` onto a user-owned host entity
4
+ // (e.g. `comment`, `note`, anything with an inserted_by_id column) calls
5
+ // this in addition to `wireCustomFieldsFor`:
6
+ //
7
+ // wireCustomFieldsFor(r, "comment", commentTable);
8
+ // wireCustomFieldsUserDataRightsFor(r, {
9
+ // entityName: "comment",
10
+ // entityTable: commentTable,
11
+ // userIdColumn: "inserted_by_id",
12
+ // });
13
+ //
14
+ // Result: a second `r.useExtension(EXT_USER_DATA, "comment", { export, delete })`
15
+ // registration whose hooks read/write the customFields jsonb column.
16
+ //
17
+ // **Export** — every row owned by the user is included; the full customFields
18
+ // jsonb travels into the user's export bundle so they can see *all* their
19
+ // custom-field data, sensitive or not (DSGVO Art. 15+20 — completeness wins).
20
+ //
21
+ // **Forget (strategy=anonymize)** — only `sensitive=true` customField keys are
22
+ // stripped from the jsonb (`customFields - 'sensitiveKey1' - 'sensitiveKey2'`).
23
+ // Non-sensitive customFields stay so the row remains useful to other tenants
24
+ // / co-authors. Matches the host-entity anonymize-then-keep contract.
25
+ //
26
+ // **Forget (strategy=delete)** — no-op. The host entity's own user-data-rights
27
+ // hook will delete the row entirely; jsonb goes with it.
28
+ //
29
+ // Side-step: this wiring requires `user-data-rights` to be installed in the
30
+ // composed feature set; if it's not, the boot-validator will reject the
31
+ // extension as unknown. That is the consumer's call — it's explicitly opt-in
32
+ // (call this function or don't), exactly because some consumers wire custom-
33
+ // fields onto tenant-owned entities (e.g. `property`) where DSGVO forget
34
+ // doesn't apply per-user.
35
+
36
+ import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
37
+ import { EXT_USER_DATA, type FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
38
+ import { getTableName, sql } from "drizzle-orm";
39
+ import type { PgTable } from "drizzle-orm/pg-core";
40
+ import { parseSerializedField } from "./lib/parse-serialized-field";
41
+
42
+ export interface WireCustomFieldsUserDataRightsOptions {
43
+ /** Host entity name as registered with wireCustomFieldsFor. */
44
+ readonly entityName: string;
45
+ /** Drizzle table for the host entity. Must have a `customFields` jsonb column. */
46
+ readonly entityTable: PgTable;
47
+ /**
48
+ * Snake-case DB column that holds the owning user's id (e.g. `inserted_by_id`,
49
+ * `author_id`, `assignee_id`). The hooks filter rows on this + tenant_id.
50
+ */
51
+ readonly userIdColumn: string;
52
+ }
53
+
54
+ interface CustomFieldsHostRow {
55
+ readonly id: string;
56
+ readonly customFields: Record<string, unknown> | null;
57
+ }
58
+
59
+ // Drizzle's raw `execute(sql\`SELECT id, custom_fields\`)` returns rows
60
+ // keyed in db-column casing (snake_case), not the field-mapping casing.
61
+ // The typeguard normalises into the camel-cased internal shape so the
62
+ // rest of the hook can stay JS-idiomatic. `in` + `instanceof Object` keep
63
+ // the narrowing cast-free.
64
+ function asCustomFieldsHostRow(value: unknown): CustomFieldsHostRow | null {
65
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
66
+ if (!("id" in value) || typeof value.id !== "string") return null;
67
+ if (!("custom_fields" in value)) return null;
68
+ const cf = value.custom_fields;
69
+ if (cf === null) return { id: value.id, customFields: null };
70
+ if (!cf || typeof cf !== "object" || Array.isArray(cf)) return null;
71
+ // Object.entries on a narrowed `object` returns `[string, unknown][]` —
72
+ // fromEntries widens that back into a typed Record without a cast.
73
+ return { id: value.id, customFields: Object.fromEntries(Object.entries(cf)) };
74
+ }
75
+
76
+ export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<string>>(
77
+ r: TReg,
78
+ opts: WireCustomFieldsUserDataRightsOptions,
79
+ ): void {
80
+ const tableName = sql.identifier(getTableName(opts.entityTable));
81
+ const userCol = sql.identifier(opts.userIdColumn);
82
+
83
+ const exportHook: UserDataExportHook = async (ctx) => {
84
+ const rowsResult = await ctx.db.execute(sql`
85
+ SELECT id, custom_fields
86
+ FROM ${tableName}
87
+ WHERE ${userCol} = ${ctx.userId} AND tenant_id = ${ctx.tenantId}
88
+ `);
89
+ const rows: ReadonlyArray<unknown> = Array.isArray(rowsResult) ? rowsResult : [];
90
+ const snippetRows: Array<{ id: string; customFields: Record<string, unknown> }> = [];
91
+ for (const raw of rows) {
92
+ const row = asCustomFieldsHostRow(raw);
93
+ // skip: drizzle-execute can hand back loosely-typed rows from raw
94
+ // queries; if a row's shape doesn't fit, skip rather than guess.
95
+ // Real schemas always match — this is defense in depth.
96
+ if (!row) continue;
97
+ const customFields = row.customFields;
98
+ if (customFields && Object.keys(customFields).length > 0) {
99
+ snippetRows.push({ id: row.id, customFields });
100
+ }
101
+ }
102
+ if (snippetRows.length === 0) return null;
103
+ return { entity: `${opts.entityName}.customFields`, rows: snippetRows };
104
+ };
105
+
106
+ const deleteHook: UserDataDeleteHook = async (ctx, strategy) => {
107
+ // skip: strategy=delete is handled by the host entity's own user-
108
+ // data-rights hook (it removes the row; customFields jsonb travels
109
+ // with it). Nothing left for this layer to do.
110
+ if (strategy === "delete") return;
111
+ const sensitiveKeys = await loadSensitiveFieldKeys(ctx.db, ctx.tenantId, opts.entityName);
112
+ // skip: no sensitive keys declared for this entity → anonymize is a
113
+ // no-op. Avoids a useless UPDATE statement.
114
+ if (sensitiveKeys.length === 0) return;
115
+
116
+ // Build the chain of jsonb minus operators: customFields - 'k1' - 'k2' - ...
117
+ const minusChain = sensitiveKeys.reduce<ReturnType<typeof sql>>(
118
+ (acc, key) => sql`${acc} - ${key}`,
119
+ sql`custom_fields`,
120
+ );
121
+ await ctx.db.execute(sql`
122
+ UPDATE ${tableName}
123
+ SET custom_fields = ${minusChain}
124
+ WHERE ${userCol} = ${ctx.userId} AND tenant_id = ${ctx.tenantId}
125
+ `);
126
+ };
127
+
128
+ // r.useExtension's options-bag accepts a structural object — pass the
129
+ // hooks inline so TS sees the literal-typed shape and Drizzle's strict
130
+ // mode doesn't reject the nominal UserDataExtensionHooks branding.
131
+ // biome-ignore lint/correctness/useHookAtTopLevel: r.useExtension is a registrar API, not a React hook.
132
+ r.useExtension(EXT_USER_DATA, opts.entityName, {
133
+ export: exportHook,
134
+ delete: deleteHook,
135
+ });
136
+ }
137
+
138
+ interface FieldDefinitionRow {
139
+ readonly field_key: string;
140
+ readonly serialized_field: unknown;
141
+ }
142
+
143
+ function isFieldDefinitionRow(value: unknown): value is FieldDefinitionRow {
144
+ if (!value || typeof value !== "object") return false;
145
+ if (!("field_key" in value)) return false;
146
+ return typeof value.field_key === "string";
147
+ }
148
+
149
+ async function loadSensitiveFieldKeys(
150
+ db: Parameters<UserDataExportHook>[0]["db"],
151
+ tenantId: string,
152
+ entityName: string,
153
+ ): Promise<string[]> {
154
+ const rowsResult = await db.execute(sql`
155
+ SELECT field_key, serialized_field
156
+ FROM read_custom_field_definitions
157
+ WHERE entity_name = ${entityName} AND tenant_id = ${tenantId}
158
+ `);
159
+ const rows: ReadonlyArray<unknown> = Array.isArray(rowsResult) ? rowsResult : [];
160
+ const keys: string[] = [];
161
+ for (const raw of rows) {
162
+ // skip: see isCustomFieldsHostRow rationale — defense in depth against
163
+ // driver shape drift.
164
+ if (!isFieldDefinitionRow(raw)) continue;
165
+ const parsed = parseSerializedField(raw.serialized_field);
166
+ if (parsed?.sensitive === true) keys.push(raw.field_key);
167
+ }
168
+ return keys;
169
+ }
@@ -1,4 +1,4 @@
1
- export const RATE_LIMITING_FEATURE = "rateLimiting" as const;
1
+ export const RATE_LIMITING_FEATURE = "rate-limiting" as const;
2
2
 
3
3
  export const RateLimitQueries = {
4
4
  status: "rate-limiting:query:status",
@@ -10,7 +10,7 @@ import { rateLimitStatus } from "./handlers/status.query";
10
10
  // only use L3 (handler-opt-in) and don't need ops introspection can
11
11
  // skip this feature entirely — the resolver still runs.
12
12
  export function createRateLimitingFeature() {
13
- return defineFeature("rateLimiting", (r) => {
13
+ return defineFeature("rate-limiting", (r) => {
14
14
  r.queryHandler(rateLimitStatus);
15
15
  });
16
16
  }
@@ -26,7 +26,7 @@ export async function adaptToFoundation(req: RenderRequest): Promise<RenderRespo
26
26
  }
27
27
 
28
28
  export function createRendererSimpleFeature(): FeatureDefinition {
29
- return defineFeature("rendererSimple", (r) => {
29
+ return defineFeature("renderer-simple", (r) => {
30
30
  r.requires("renderer-foundation");
31
31
 
32
32
  r.useExtension("renderer", "simple", {
@@ -21,7 +21,9 @@ export const templateResourceEntity = createEntity({
21
21
  slug: createTextField({ required: true }),
22
22
  kind: createSelectField({ required: true, options: [...RENDER_KINDS] }),
23
23
  locale: createTextField({ required: true }),
24
- content: createLongTextField({}),
24
+ // Template-body is authored by TenantAdmin/Operator (email-templates etc.),
25
+ // business data — kein end-user UGC.
26
+ content: createLongTextField({ allowPlaintext: "is-business-data" }),
25
27
  contentFormat: createSelectField({ required: true, options: [...CONTENT_FORMATS] }),
26
28
  variableSchema: createLongTextField({}),
27
29
  linkedResources: createLongTextField({}),
@@ -57,7 +57,8 @@ export const tenantInvitationEntity = createEntity({
57
57
  table: "read_tenant_invitations",
58
58
  fields: {
59
59
  // Eingeladene Email — case-insensitive normalisiert beim Insert.
60
- email: createTextField({ required: true, maxLength: 320 }),
60
+ // PII bis zur Annahme (danach hat der User selbst seine email in users).
61
+ email: createTextField({ required: true, maxLength: 320, pii: true }),
61
62
  // Membership-Rolle die dem User nach Accept gegeben wird. Default
62
63
  // im handler ist "Admin" (Co-Admin-Pattern für kleine Teams).
63
64
  role: createTextField({ required: true, maxLength: 50 }),
@@ -15,7 +15,9 @@ export const textBlockEntity = createEntity({
15
15
  slug: createTextField({ required: true }),
16
16
  lang: createTextField({ required: true }),
17
17
  title: createTextField({ required: true }),
18
- body: createTextField({}),
18
+ // Body is CMS-content authored by TenantAdmin/SystemAdmin (legal pages,
19
+ // FAQ, ToS, marketing) — business data, not user-generated content.
20
+ body: createTextField({ allowPlaintext: "is-business-data" }),
19
21
  // V.1.4: explicit folder-Hierarchie statt `:`-Encoding im Slug.
20
22
  // Visual-Tree gruppiert nach diesem Field; null/undefined → root.
21
23
  // Pflicht-Constraint im set.write-Schema (kebab-only wie slug,