@cosmicdrift/kumiko-bundled-features 0.12.1 → 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 +116 -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,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
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
+
}
|
|
@@ -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("
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|