@cosmicdrift/kumiko-bundled-features 0.24.0 → 0.25.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/package.json +1 -1
- package/src/__tests__/env-schemas.test.ts +53 -11
- package/src/auth-email-password/__tests__/auth.integration.test.ts +37 -0
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +32 -0
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +31 -0
- package/src/auth-email-password/handlers/change-password.write.ts +12 -2
- package/src/auth-email-password/handlers/confirm-token-flow.ts +17 -2
- package/src/compliance-profiles/__tests__/parse-override.test.ts +53 -0
- package/src/compliance-profiles/_internal/parse-override.ts +8 -7
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +28 -1
- package/src/custom-fields/__tests__/cross-tenant-set-write.integration.test.ts +178 -0
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +139 -0
- package/src/custom-fields/__tests__/drift.test.ts +43 -0
- package/src/custom-fields/__tests__/field-access.integration.test.ts +59 -0
- package/src/custom-fields/__tests__/field-definition-row.test.ts +62 -0
- package/src/custom-fields/__tests__/retention.integration.test.ts +76 -0
- package/src/custom-fields/__tests__/value-schema.test.ts +54 -0
- package/src/custom-fields/__tests__/wire-for-entity.test.ts +29 -0
- package/src/custom-fields/constants.ts +8 -7
- package/src/custom-fields/db/queries/projection.ts +19 -7
- package/src/custom-fields/db/queries/retention.ts +20 -6
- package/src/custom-fields/executor.ts +10 -0
- package/src/custom-fields/feature.ts +32 -39
- package/src/custom-fields/handlers/clear-custom-field.write.ts +8 -1
- package/src/custom-fields/handlers/define-system-field.write.ts +5 -22
- package/src/custom-fields/handlers/define-tenant-field.write.ts +13 -23
- package/src/custom-fields/handlers/delete-system-field.write.ts +3 -9
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +3 -9
- package/src/custom-fields/handlers/set-custom-field.write.ts +8 -1
- package/src/custom-fields/lib/field-access.ts +9 -4
- package/src/custom-fields/lib/field-definition-row.ts +33 -0
- package/src/custom-fields/lib/value-schema.ts +14 -2
- package/src/custom-fields/run-retention.ts +6 -5
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +45 -10
- package/src/custom-fields/web/client-plugin.tsx +2 -0
- package/src/custom-fields/web/custom-fields-form-section.tsx +19 -9
- package/src/custom-fields/web/i18n.ts +30 -0
- package/src/custom-fields/wire-for-entity.ts +9 -2
- package/src/custom-fields/wire-user-data-rights.ts +9 -0
- package/src/feature-toggles/handlers/set.write.ts +13 -8
- package/src/secrets/feature.ts +4 -11
- package/src/subscription-stripe/feature.ts +2 -2
- package/src/template-resolver/handlers/list.query.ts +12 -10
- package/src/tenant/__tests__/seed-testing.integration.test.ts +26 -0
- package/src/tenant/seeding.ts +3 -3
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +11 -11
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +26 -74
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +211 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +272 -0
- package/src/user-data-rights/__tests__/forget-test-helpers.ts +86 -0
- package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
- package/src/user-data-rights/run-forget-cleanup.ts +77 -36
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +21 -6
|
@@ -37,7 +37,12 @@
|
|
|
37
37
|
// Value-Validation gegen serializedField → set-custom-field via fieldToZod —
|
|
38
38
|
// alle erledigt.)
|
|
39
39
|
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
defineEntityListHandler,
|
|
42
|
+
defineFeature,
|
|
43
|
+
type FeatureRegistrar,
|
|
44
|
+
type WriteHandlerDef,
|
|
45
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
41
46
|
import { z } from "zod";
|
|
42
47
|
import {
|
|
43
48
|
CUSTOM_FIELD_CLEARED_EVENT,
|
|
@@ -71,12 +76,14 @@ const fieldDefinitionDeletedSchema = z.object({
|
|
|
71
76
|
tenantId: z.string().optional(),
|
|
72
77
|
});
|
|
73
78
|
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
// Shared registration body for both the singleton and the quota-variant.
|
|
80
|
+
// Only the define-tenant-field handler differs (quota baked in or not); every
|
|
81
|
+
// other entity/event/handler is registered identically here so a new
|
|
82
|
+
// event/handler can never silently miss the quota variant.
|
|
83
|
+
function registerCustomFields(
|
|
84
|
+
r: FeatureRegistrar<typeof CUSTOM_FIELDS_FEATURE_NAME>,
|
|
85
|
+
defineTenantHandler: WriteHandlerDef,
|
|
86
|
+
) {
|
|
80
87
|
r.entity("field-definition", fieldDefinitionEntity);
|
|
81
88
|
|
|
82
89
|
// Event-types — qualified als "custom-fields:event:<short-name>".
|
|
@@ -101,7 +108,7 @@ export const customFieldsFeature = defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r)
|
|
|
101
108
|
r.extendsRegistrar(CUSTOM_FIELDS_EXTENSION, {});
|
|
102
109
|
|
|
103
110
|
// Definition-CRUD handlers (B1).
|
|
104
|
-
r.writeHandler(
|
|
111
|
+
r.writeHandler(defineTenantHandler);
|
|
105
112
|
r.writeHandler(defineSystemFieldHandler);
|
|
106
113
|
r.writeHandler(deleteTenantFieldHandler);
|
|
107
114
|
r.writeHandler(deleteSystemFieldHandler);
|
|
@@ -116,7 +123,16 @@ export const customFieldsFeature = defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r)
|
|
|
116
123
|
);
|
|
117
124
|
|
|
118
125
|
return { setEvent, clearedEvent, fieldDefinitionDeletedEvent };
|
|
119
|
-
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Singleton feature-definition mit typed exports. Handler + wire-for-entity
|
|
129
|
+
// importieren diesen `customFieldsFeature` und greifen lazy in ihrer
|
|
130
|
+
// runtime-arrow-fn auf `.exports.<event>.name` zu — der module-cycle
|
|
131
|
+
// (feature.ts -> handlers/*.write.ts -> feature.ts) löst sich auf weil
|
|
132
|
+
// kein top-level-access stattfindet.
|
|
133
|
+
export const customFieldsFeature = defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) =>
|
|
134
|
+
registerCustomFields(r, defineTenantFieldHandler),
|
|
135
|
+
);
|
|
120
136
|
|
|
121
137
|
// Backwards-compat-wrapper. Bestehende Caller (z.B. integration-tests,
|
|
122
138
|
// host-apps) nutzen weiterhin `createCustomFieldsFeature()`. Returnt den
|
|
@@ -133,34 +149,11 @@ export function createCustomFieldsFeature(
|
|
|
133
149
|
if (opts.fieldDefinitionLimitPerTenant === undefined) {
|
|
134
150
|
return customFieldsFeature;
|
|
135
151
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
fieldDefinitionDeletedSchema,
|
|
144
|
-
);
|
|
145
|
-
|
|
146
|
-
r.extendsRegistrar(CUSTOM_FIELDS_EXTENSION, {});
|
|
147
|
-
|
|
148
|
-
r.writeHandler(
|
|
149
|
-
createDefineTenantFieldHandler({
|
|
150
|
-
fieldDefinitionLimitPerTenant: opts.fieldDefinitionLimitPerTenant,
|
|
151
|
-
}),
|
|
152
|
-
);
|
|
153
|
-
r.writeHandler(defineSystemFieldHandler);
|
|
154
|
-
r.writeHandler(deleteTenantFieldHandler);
|
|
155
|
-
r.writeHandler(deleteSystemFieldHandler);
|
|
156
|
-
|
|
157
|
-
r.writeHandler(setCustomFieldHandler);
|
|
158
|
-
r.writeHandler(clearCustomFieldHandler);
|
|
159
|
-
|
|
160
|
-
r.queryHandler(
|
|
161
|
-
defineEntityListHandler("field-definition", fieldDefinitionEntity, tenantAdminAccess),
|
|
162
|
-
);
|
|
163
|
-
|
|
164
|
-
return { setEvent, clearedEvent, fieldDefinitionDeletedEvent };
|
|
165
|
-
});
|
|
152
|
+
const limit = opts.fieldDefinitionLimitPerTenant;
|
|
153
|
+
return defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) =>
|
|
154
|
+
registerCustomFields(
|
|
155
|
+
r,
|
|
156
|
+
createDefineTenantFieldHandler({ fieldDefinitionLimitPerTenant: limit }),
|
|
157
|
+
),
|
|
158
|
+
);
|
|
166
159
|
}
|
|
@@ -7,7 +7,11 @@ import { checkFieldAccessForWrite } from "../lib/field-access";
|
|
|
7
7
|
export const clearCustomFieldPayloadSchema = z.object({
|
|
8
8
|
entityName: z.string().min(1).max(64),
|
|
9
9
|
entityId: z.string().min(1),
|
|
10
|
-
fieldKey: z
|
|
10
|
+
fieldKey: z
|
|
11
|
+
.string()
|
|
12
|
+
.min(1)
|
|
13
|
+
.max(64)
|
|
14
|
+
.regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/),
|
|
11
15
|
});
|
|
12
16
|
export type ClearCustomFieldPayload = z.infer<typeof clearCustomFieldPayloadSchema>;
|
|
13
17
|
|
|
@@ -36,6 +40,9 @@ export const clearCustomFieldHandler: WriteHandlerDef = {
|
|
|
36
40
|
if (accessCheck.reason === "field_definition_not_found") {
|
|
37
41
|
return failNotFound("fieldDefinition", payload.fieldKey);
|
|
38
42
|
}
|
|
43
|
+
if (accessCheck.reason === "field_definition_corrupt") {
|
|
44
|
+
return failUnprocessable("field_definition_corrupt", { fieldKey: payload.fieldKey });
|
|
45
|
+
}
|
|
39
46
|
return failUnprocessable("field_access_denied", {
|
|
40
47
|
fieldKey: payload.fieldKey,
|
|
41
48
|
requiredRoles: accessCheck.requiredRoles ?? [],
|
|
@@ -1,14 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createEntityExecutor,
|
|
3
|
-
SYSTEM_TENANT_ID,
|
|
4
|
-
type WriteHandlerDef,
|
|
5
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { SYSTEM_TENANT_ID, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
2
|
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
7
|
-
import {
|
|
3
|
+
import { fieldDefinitionExecutor } from "../executor";
|
|
4
|
+
import { buildFieldDefinitionColumns } from "../lib/field-definition-row";
|
|
8
5
|
import { type DefineFieldPayload, defineFieldPayloadSchema } from "../schemas";
|
|
9
6
|
|
|
10
|
-
const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
|
|
11
|
-
|
|
12
7
|
// define-system-field — SystemAdmin definiert eine system-weite Custom-Field-
|
|
13
8
|
// Definition die für ALLE Tenants gilt. tenantId wird auf SYSTEM_TENANT_ID
|
|
14
9
|
// gesetzt (NICHT vom Caller — SystemAdmin's event.user.tenantId würde sonst
|
|
@@ -38,20 +33,8 @@ export const defineSystemFieldHandler: WriteHandlerDef = {
|
|
|
38
33
|
// — the row lives in the system-scope-stream.
|
|
39
34
|
const systemUser = { ...event.user, tenantId: SYSTEM_TENANT_ID };
|
|
40
35
|
|
|
41
|
-
return
|
|
42
|
-
{
|
|
43
|
-
id: aggregateId,
|
|
44
|
-
entityName: payload.entityName,
|
|
45
|
-
fieldKey: payload.fieldKey,
|
|
46
|
-
type: payload.serializedField.type,
|
|
47
|
-
required: payload.required,
|
|
48
|
-
searchable: payload.searchable,
|
|
49
|
-
displayOrder: payload.displayOrder,
|
|
50
|
-
serializedField: JSON.stringify({
|
|
51
|
-
...payload.serializedField,
|
|
52
|
-
label: payload.label,
|
|
53
|
-
}),
|
|
54
|
-
},
|
|
36
|
+
return fieldDefinitionExecutor.create(
|
|
37
|
+
{ id: aggregateId, ...buildFieldDefinitionColumns(payload) },
|
|
55
38
|
systemUser,
|
|
56
39
|
ctx.db,
|
|
57
40
|
);
|
|
@@ -1,16 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createEntityExecutor,
|
|
3
|
-
isSystemTenant,
|
|
4
|
-
type WriteHandlerDef,
|
|
5
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { isSystemTenant, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
2
|
import { failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
|
|
7
3
|
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
8
|
-
import {
|
|
4
|
+
import { fieldDefinitionExecutor } from "../executor";
|
|
5
|
+
import { buildFieldDefinitionColumns } from "../lib/field-definition-row";
|
|
9
6
|
import { countTenantFieldDefinitions } from "../lib/quota";
|
|
10
7
|
import { type DefineFieldPayload, defineFieldPayloadSchema } from "../schemas";
|
|
11
8
|
|
|
12
|
-
const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
|
|
13
|
-
|
|
14
9
|
// define-tenant-field — TenantAdmin definiert eine Custom-Field-Definition
|
|
15
10
|
// für seinen eigenen Tenant. tenantId wird automatisch aus event.user.tenantId
|
|
16
11
|
// abgeleitet (NICHT vom Caller setzbar — verhindert Cross-Tenant-Mutation).
|
|
@@ -32,8 +27,15 @@ const { executor } = createEntityExecutor("field-definition", fieldDefinitionEnt
|
|
|
32
27
|
// `unprocessable` + reason `cap_exceeded` BEFORE attempting the insert.
|
|
33
28
|
// The factory below closes over the limit; the legacy const-export keeps
|
|
34
29
|
// behavior unchanged for callers who didn't opt into a limit.
|
|
30
|
+
//
|
|
31
|
+
// Soft cap, not a hard guarantee: the count-then-insert is not serialized, so
|
|
32
|
+
// N concurrent defines with distinct fieldKeys can each read `current < limit`
|
|
33
|
+
// and overshoot by up to N. Acceptable here — defining fields is an admin-only,
|
|
34
|
+
// low-frequency action and the limit is not wired to billing/tier enforcement.
|
|
35
|
+
// If an exact cap is ever needed, serialize via advisory lock or a count
|
|
36
|
+
// constraint at the insert.
|
|
35
37
|
export interface DefineTenantFieldOptions {
|
|
36
|
-
/**
|
|
38
|
+
/** Soft cap — `>= limit` definitions per tenant rejects further defines (see header: concurrent defines may overshoot). */
|
|
37
39
|
readonly fieldDefinitionLimitPerTenant?: number;
|
|
38
40
|
}
|
|
39
41
|
|
|
@@ -73,20 +75,8 @@ export function createDefineTenantFieldHandler(
|
|
|
73
75
|
payload.fieldKey,
|
|
74
76
|
);
|
|
75
77
|
|
|
76
|
-
return
|
|
77
|
-
{
|
|
78
|
-
id: aggregateId,
|
|
79
|
-
entityName: payload.entityName,
|
|
80
|
-
fieldKey: payload.fieldKey,
|
|
81
|
-
type: payload.serializedField.type,
|
|
82
|
-
required: payload.required,
|
|
83
|
-
searchable: payload.searchable,
|
|
84
|
-
displayOrder: payload.displayOrder,
|
|
85
|
-
serializedField: JSON.stringify({
|
|
86
|
-
...payload.serializedField,
|
|
87
|
-
label: payload.label,
|
|
88
|
-
}),
|
|
89
|
-
},
|
|
78
|
+
return fieldDefinitionExecutor.create(
|
|
79
|
+
{ id: aggregateId, ...buildFieldDefinitionColumns(payload) },
|
|
90
80
|
event.user,
|
|
91
81
|
ctx.db,
|
|
92
82
|
);
|
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createEntityExecutor,
|
|
3
|
-
SYSTEM_TENANT_ID,
|
|
4
|
-
type WriteHandlerDef,
|
|
5
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { SYSTEM_TENANT_ID, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
2
|
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
7
|
-
import {
|
|
3
|
+
import { fieldDefinitionExecutor } from "../executor";
|
|
8
4
|
import { customFieldsFeature } from "../feature";
|
|
9
5
|
import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
|
|
10
6
|
|
|
11
|
-
const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
|
|
12
|
-
|
|
13
7
|
// delete-system-field — SystemAdmin entfernt eine system-weite Field-
|
|
14
8
|
// Definition. Konsequenz: KEIN Tenant kann mehr neue Werte dafür setzen,
|
|
15
9
|
// existing Werte in read_<entity>.customFields jsonb bleiben aber bestehen
|
|
@@ -29,7 +23,7 @@ export const deleteSystemFieldHandler: WriteHandlerDef = {
|
|
|
29
23
|
);
|
|
30
24
|
|
|
31
25
|
const systemUser = { ...event.user, tenantId: SYSTEM_TENANT_ID };
|
|
32
|
-
const result = await
|
|
26
|
+
const result = await fieldDefinitionExecutor.delete({ id: aggregateId }, systemUser, ctx.db);
|
|
33
27
|
|
|
34
28
|
// Cascade-cleanup-Event — host-entity-MSPs entfernen orphan values aus
|
|
35
29
|
// ihrer customFields jsonb. Im selben TX = atomic.
|
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createEntityExecutor,
|
|
3
|
-
isSystemTenant,
|
|
4
|
-
type WriteHandlerDef,
|
|
5
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { isSystemTenant, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
2
|
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
7
|
-
import {
|
|
3
|
+
import { fieldDefinitionExecutor } from "../executor";
|
|
8
4
|
import { customFieldsFeature } from "../feature";
|
|
9
5
|
import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
|
|
10
6
|
|
|
11
|
-
const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
|
|
12
|
-
|
|
13
7
|
// delete-tenant-field — TenantAdmin löscht eigene Field-Definition.
|
|
14
8
|
// Spec-Promise (Plan-Doc v2 "wie Entity-Delete"): Events bleiben im event-
|
|
15
9
|
// store für Audit-Trail. Read-Projection-Row wird entfernt. B2 wird die
|
|
@@ -36,7 +30,7 @@ export const deleteTenantFieldHandler: WriteHandlerDef = {
|
|
|
36
30
|
|
|
37
31
|
const aggregateId = fieldDefinitionAggregateId(tenantId, payload.entityName, payload.fieldKey);
|
|
38
32
|
|
|
39
|
-
const result = await
|
|
33
|
+
const result = await fieldDefinitionExecutor.delete({ id: aggregateId }, event.user, ctx.db);
|
|
40
34
|
|
|
41
35
|
// Emit cascade-cleanup-Event NACH erfolgreichem Delete. host-entity-MSPs
|
|
42
36
|
// (registriert via wireCustomFieldsFor) konsumieren das + entfernen orphan
|
|
@@ -13,7 +13,11 @@ export const setCustomFieldPayloadSchema = z.object({
|
|
|
13
13
|
.min(1)
|
|
14
14
|
.max(64)
|
|
15
15
|
.regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/),
|
|
16
|
-
|
|
16
|
+
// z.unknown() is implicitly optional; reject a missing value here (clearing is
|
|
17
|
+
// clear-custom-field's job) so the projection never binds JSON.stringify(undefined).
|
|
18
|
+
value: z
|
|
19
|
+
.unknown()
|
|
20
|
+
.refine((v) => v !== undefined, "value is required (use clear-custom-field to remove a value)"),
|
|
17
21
|
});
|
|
18
22
|
export type SetCustomFieldPayload = z.infer<typeof setCustomFieldPayloadSchema>;
|
|
19
23
|
|
|
@@ -57,6 +61,9 @@ export const setCustomFieldHandler: WriteHandlerDef = {
|
|
|
57
61
|
if (!loaded.found) {
|
|
58
62
|
return failNotFound("fieldDefinition", payload.fieldKey);
|
|
59
63
|
}
|
|
64
|
+
if (loaded.field === null) {
|
|
65
|
+
return failUnprocessable("field_definition_corrupt", { fieldKey: payload.fieldKey });
|
|
66
|
+
}
|
|
60
67
|
|
|
61
68
|
const deniedRoles = fieldWriteAccessDeniedRoles(loaded.field, event.user.roles);
|
|
62
69
|
if (deniedRoles) {
|
|
@@ -10,15 +10,15 @@ export type FieldAccessCheckResult =
|
|
|
10
10
|
| { ok: true }
|
|
11
11
|
| {
|
|
12
12
|
ok: false;
|
|
13
|
-
reason: "field_definition_not_found" | "field_access_denied";
|
|
13
|
+
reason: "field_definition_not_found" | "field_definition_corrupt" | "field_access_denied";
|
|
14
14
|
requiredRoles?: ReadonlyArray<string>;
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
export type LoadedFieldDefinition =
|
|
18
18
|
| { found: false }
|
|
19
|
-
// `field` is null when the row exists but its serialized_field is corrupt
|
|
20
|
-
//
|
|
21
|
-
//
|
|
19
|
+
// `field` is null when the row exists but its serialized_field is corrupt.
|
|
20
|
+
// A write access-gate treats this fail-closed (secure-by-default): a corrupt
|
|
21
|
+
// definition must not silently drop a per-field write restriction.
|
|
22
22
|
| { found: true; field: SerializedFieldShape | null };
|
|
23
23
|
|
|
24
24
|
export async function loadFieldDefinition(
|
|
@@ -34,6 +34,10 @@ export async function loadFieldDefinition(
|
|
|
34
34
|
|
|
35
35
|
// Pure access-check on an already-loaded definition. Returns the required
|
|
36
36
|
// roles when the caller is denied, or `null` when access is allowed.
|
|
37
|
+
//
|
|
38
|
+
// Contract: `fieldAccess.write` entries MUST be verbatim membership-system role
|
|
39
|
+
// names (e.g. "TenantAdmin", not "Admin") — match is exact like framework-wide
|
|
40
|
+
// `engine/access.ts`; no normalization, so a drifted name denies silently.
|
|
37
41
|
export function fieldWriteAccessDeniedRoles(
|
|
38
42
|
field: SerializedFieldShape | null,
|
|
39
43
|
userRoles: ReadonlyArray<string>,
|
|
@@ -54,6 +58,7 @@ export async function checkFieldAccessForWrite(
|
|
|
54
58
|
): Promise<FieldAccessCheckResult> {
|
|
55
59
|
const loaded = await loadFieldDefinition(db, tenantId, entityName, fieldKey);
|
|
56
60
|
if (!loaded.found) return { ok: false, reason: "field_definition_not_found" };
|
|
61
|
+
if (loaded.field === null) return { ok: false, reason: "field_definition_corrupt" };
|
|
57
62
|
|
|
58
63
|
const deniedRoles = fieldWriteAccessDeniedRoles(loaded.field, userRoles);
|
|
59
64
|
if (!deniedRoles) return { ok: true };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { DefineFieldPayload } from "../schemas";
|
|
2
|
+
|
|
3
|
+
export interface FieldDefinitionColumns {
|
|
4
|
+
readonly entityName: string;
|
|
5
|
+
readonly fieldKey: string;
|
|
6
|
+
readonly type: string;
|
|
7
|
+
readonly required: boolean;
|
|
8
|
+
readonly searchable: boolean;
|
|
9
|
+
readonly displayOrder: number;
|
|
10
|
+
readonly serializedField: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The required/searchable/displayOrder columns are a denormalized projection of
|
|
14
|
+
// serializedField — derive, don't default. Zod gives top-level `required` a
|
|
15
|
+
// `.default(false)`, so "caller omitted it" is indistinguishable from `false`
|
|
16
|
+
// post-parse; serializedField-present therefore wins to preserve caller intent.
|
|
17
|
+
export function buildFieldDefinitionColumns(payload: DefineFieldPayload): FieldDefinitionColumns {
|
|
18
|
+
const sf = payload.serializedField;
|
|
19
|
+
const required = typeof sf["required"] === "boolean" ? sf["required"] : payload.required;
|
|
20
|
+
const searchable = typeof sf["searchable"] === "boolean" ? sf["searchable"] : payload.searchable;
|
|
21
|
+
const displayOrder =
|
|
22
|
+
typeof sf["displayOrder"] === "number" ? sf["displayOrder"] : payload.displayOrder;
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
entityName: payload.entityName,
|
|
26
|
+
fieldKey: payload.fieldKey,
|
|
27
|
+
type: sf.type,
|
|
28
|
+
required,
|
|
29
|
+
searchable,
|
|
30
|
+
displayOrder,
|
|
31
|
+
serializedField: JSON.stringify({ ...sf, label: payload.label }),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -47,20 +47,32 @@ export function buildCustomFieldValueSchema(parsedField: unknown): z.ZodTypeAny
|
|
|
47
47
|
// Embedded sub-fields: pre-check the sub-type set so we surface unknown
|
|
48
48
|
// sub-types as "skip validation" (return null) rather than letting
|
|
49
49
|
// fieldToZod's assertUnreachable throw and the catch swallow real bugs.
|
|
50
|
+
// Build a constraint-stripped copy of each sub-field in the same pass —
|
|
51
|
+
// symmetric with the top-level strip below. Without it a sub-field's
|
|
52
|
+
// `required` folds into z.string().min(1) + non-optional, re-enforcing a
|
|
53
|
+
// constraint the contract ("type-mismatches and ONLY type-mismatches")
|
|
54
|
+
// drops everywhere else.
|
|
55
|
+
let strippedSubSchema: Record<string, Record<string, unknown>> | undefined;
|
|
50
56
|
if (rawType === "embedded") {
|
|
51
57
|
const schema = obj["schema"];
|
|
52
58
|
if (!schema || typeof schema !== "object") return null;
|
|
53
|
-
|
|
59
|
+
strippedSubSchema = {};
|
|
60
|
+
for (const [subKey, sub] of Object.entries(schema)) {
|
|
54
61
|
if (!sub || typeof sub !== "object") return null;
|
|
55
|
-
const
|
|
62
|
+
const subObj = sub as Record<string, unknown>;
|
|
63
|
+
const subType = subObj["type"];
|
|
56
64
|
if (typeof subType !== "string" || !SUPPORTED_EMBEDDED_SUB_TYPES.has(subType)) {
|
|
57
65
|
return null;
|
|
58
66
|
}
|
|
67
|
+
const strippedSub = { ...subObj };
|
|
68
|
+
for (const k of CONSTRAINT_KEYS) delete strippedSub[k];
|
|
69
|
+
strippedSubSchema[subKey] = strippedSub;
|
|
59
70
|
}
|
|
60
71
|
}
|
|
61
72
|
|
|
62
73
|
const fieldDef: Record<string, unknown> = { ...obj };
|
|
63
74
|
for (const k of CONSTRAINT_KEYS) delete fieldDef[k];
|
|
75
|
+
if (strippedSubSchema !== undefined) fieldDef["schema"] = strippedSubSchema;
|
|
64
76
|
if (rawType === "enum") {
|
|
65
77
|
fieldDef["type"] = "select";
|
|
66
78
|
fieldDef["options"] = obj["values"] ?? obj["options"] ?? [];
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
19
19
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
20
20
|
import {
|
|
21
|
+
applyRetentionRemovals,
|
|
21
22
|
selectFieldDefinitionsWithSerialized,
|
|
22
23
|
selectHostRowsWithCustomFields,
|
|
23
|
-
updateHostRowCustomFields,
|
|
24
24
|
} from "./db/queries/retention";
|
|
25
25
|
import { parseSerializedField } from "./lib/parse-serialized-field";
|
|
26
26
|
|
|
@@ -122,17 +122,18 @@ export async function runCustomFieldsRetention(
|
|
|
122
122
|
// skip: nothing on this row aged out — no UPDATE needed.
|
|
123
123
|
if (removals.length === 0) continue;
|
|
124
124
|
|
|
125
|
-
const
|
|
125
|
+
const deleteKeys: string[] = [];
|
|
126
|
+
const anonymizeKeys: string[] = [];
|
|
126
127
|
for (const { key, strategy } of removals) {
|
|
127
128
|
if (strategy === "delete") {
|
|
128
|
-
|
|
129
|
+
deleteKeys.push(key);
|
|
129
130
|
} else {
|
|
130
|
-
|
|
131
|
+
anonymizeKeys.push(key);
|
|
131
132
|
}
|
|
132
133
|
removalsByFieldKey[key] = (removalsByFieldKey[key] ?? 0) + 1;
|
|
133
134
|
}
|
|
134
135
|
|
|
135
|
-
await
|
|
136
|
+
await applyRetentionRemovals(opts.db, tableName, deleteKeys, anonymizeKeys, row.id);
|
|
136
137
|
rowsUpdated++;
|
|
137
138
|
}
|
|
138
139
|
|
|
@@ -8,6 +8,7 @@ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
|
8
8
|
import { fireEvent, render, screen } from "@testing-library/react";
|
|
9
9
|
import type { ReactNode } from "react";
|
|
10
10
|
import { CustomFieldsFormSection } from "../custom-fields-form-section";
|
|
11
|
+
import { defaultTranslations } from "../i18n";
|
|
11
12
|
|
|
12
13
|
type FieldRow = {
|
|
13
14
|
id: string;
|
|
@@ -21,6 +22,13 @@ type FieldRow = {
|
|
|
21
22
|
const dispatchSpy = mock(async () => ({ isSuccess: true, data: undefined }));
|
|
22
23
|
let mockedQueryRows: readonly FieldRow[] = [];
|
|
23
24
|
|
|
25
|
+
const useQuerySpy = mock((_type: string, _params: unknown, _options?: { enabled?: boolean }) => ({
|
|
26
|
+
data: { rows: mockedQueryRows },
|
|
27
|
+
loading: false,
|
|
28
|
+
error: null,
|
|
29
|
+
refetch: mock(),
|
|
30
|
+
}));
|
|
31
|
+
|
|
24
32
|
const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
|
|
25
33
|
mock.module("@cosmicdrift/kumiko-renderer", () => ({
|
|
26
34
|
...actual_renderer,
|
|
@@ -29,17 +37,12 @@ mock.module("@cosmicdrift/kumiko-renderer", () => ({
|
|
|
29
37
|
query: mock(),
|
|
30
38
|
batch: mock(),
|
|
31
39
|
})),
|
|
32
|
-
useQuery:
|
|
33
|
-
data: { rows: mockedQueryRows },
|
|
34
|
-
loading: false,
|
|
35
|
-
error: null,
|
|
36
|
-
refetch: mock(),
|
|
37
|
-
})),
|
|
40
|
+
useQuery: useQuerySpy,
|
|
38
41
|
}));
|
|
39
42
|
|
|
40
43
|
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
41
44
|
return (
|
|
42
|
-
<LocaleProvider resolver={createStaticLocaleResolver()}>
|
|
45
|
+
<LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
|
|
43
46
|
<PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
|
|
44
47
|
</LocaleProvider>
|
|
45
48
|
);
|
|
@@ -107,8 +110,9 @@ describe("CustomFieldsFormSection", () => {
|
|
|
107
110
|
});
|
|
108
111
|
});
|
|
109
112
|
|
|
110
|
-
test("shows create-mode banner when entityId is null", () => {
|
|
113
|
+
test("shows create-mode banner when entityId is null and skips the fieldDefinition query", () => {
|
|
111
114
|
mockedQueryRows = [];
|
|
115
|
+
useQuerySpy.mockClear();
|
|
112
116
|
|
|
113
117
|
render(
|
|
114
118
|
<Wrapper>
|
|
@@ -116,8 +120,14 @@ describe("CustomFieldsFormSection", () => {
|
|
|
116
120
|
</Wrapper>,
|
|
117
121
|
);
|
|
118
122
|
|
|
119
|
-
|
|
123
|
+
const banner = screen.getByTestId("custom-fields-form-create-mode");
|
|
124
|
+
expect(banner).toBeTruthy();
|
|
120
125
|
expect(screen.queryByTestId("custom-fields-form-section")).toBeNull();
|
|
126
|
+
// The banner shows the translated string, not the raw i18n key.
|
|
127
|
+
expect(banner.textContent).toBe("Save the entity first to add custom field values.");
|
|
128
|
+
// create-mode discards the query result via the early return, so the
|
|
129
|
+
// fetch-on-mount must be disabled — no wasted server roundtrip.
|
|
130
|
+
expect(useQuerySpy.mock.calls[0]?.[2]).toEqual({ enabled: false });
|
|
121
131
|
});
|
|
122
132
|
|
|
123
133
|
test("shows empty banner when no fieldDefinitions match entityName", () => {
|
|
@@ -138,7 +148,32 @@ describe("CustomFieldsFormSection", () => {
|
|
|
138
148
|
</Wrapper>,
|
|
139
149
|
);
|
|
140
150
|
|
|
141
|
-
|
|
151
|
+
const banner = screen.getByTestId("custom-fields-form-empty");
|
|
152
|
+
expect(banner).toBeTruthy();
|
|
142
153
|
expect(screen.queryByTestId("custom-fields-form-section")).toBeNull();
|
|
154
|
+
// Translated + interpolated with the host entity name, not the raw key.
|
|
155
|
+
expect(banner.textContent).toBe('No custom fields defined for "component".');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("save button renders the translated label, not the raw i18n key", () => {
|
|
159
|
+
mockedQueryRows = [
|
|
160
|
+
{
|
|
161
|
+
id: "f1",
|
|
162
|
+
entityName: "component",
|
|
163
|
+
fieldKey: "vendor",
|
|
164
|
+
type: "text",
|
|
165
|
+
required: false,
|
|
166
|
+
displayOrder: 1,
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
render(
|
|
171
|
+
<Wrapper>
|
|
172
|
+
<CustomFieldsFormSection entityName="component" entityId="row-42" />
|
|
173
|
+
</Wrapper>,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const saveBtn = screen.getByTestId("custom-fields-form-save");
|
|
177
|
+
expect(saveBtn.textContent).toBe("Save custom fields");
|
|
143
178
|
});
|
|
144
179
|
});
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
15
15
|
import { CUSTOM_FIELDS_FEATURE_NAME, CUSTOM_FIELDS_FORM_EXTENSION_NAME } from "../constants";
|
|
16
16
|
import { CustomFieldsFormSection } from "./custom-fields-form-section";
|
|
17
|
+
import { defaultTranslations } from "./i18n";
|
|
17
18
|
|
|
18
19
|
export function customFieldsClient(): ClientFeatureDefinition {
|
|
19
20
|
return {
|
|
@@ -21,5 +22,6 @@ export function customFieldsClient(): ClientFeatureDefinition {
|
|
|
21
22
|
extensionSectionComponents: {
|
|
22
23
|
[CUSTOM_FIELDS_FORM_EXTENSION_NAME]: CustomFieldsFormSection,
|
|
23
24
|
},
|
|
25
|
+
translations: defaultTranslations,
|
|
24
26
|
};
|
|
25
27
|
}
|
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
// `component: { react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME } }`
|
|
11
11
|
// referenziert.
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
useDispatcher,
|
|
15
|
+
usePrimitives,
|
|
16
|
+
useQuery,
|
|
17
|
+
useTranslation,
|
|
18
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
14
19
|
import { type ReactNode, useState } from "react";
|
|
15
20
|
import { CustomFieldsHandlers, CustomFieldsQueries } from "../constants";
|
|
16
21
|
|
|
@@ -35,8 +40,13 @@ export function CustomFieldsFormSection({
|
|
|
35
40
|
readonly entityId: string | null;
|
|
36
41
|
}): ReactNode {
|
|
37
42
|
const { Banner, Button, Field, Input, Text } = usePrimitives();
|
|
43
|
+
const t = useTranslation();
|
|
38
44
|
const dispatcher = useDispatcher();
|
|
39
|
-
const query = useQuery<FieldDefinitionListResponse>(
|
|
45
|
+
const query = useQuery<FieldDefinitionListResponse>(
|
|
46
|
+
CustomFieldsQueries.fieldDefinitionList,
|
|
47
|
+
{},
|
|
48
|
+
{ enabled: entityId !== null },
|
|
49
|
+
);
|
|
40
50
|
const [pending, setPending] = useState<Readonly<Record<string, string>>>({});
|
|
41
51
|
const [saving, setSaving] = useState(false);
|
|
42
52
|
const [errorKey, setErrorKey] = useState<string | null>(null);
|
|
@@ -44,21 +54,21 @@ export function CustomFieldsFormSection({
|
|
|
44
54
|
if (entityId === null) {
|
|
45
55
|
return (
|
|
46
56
|
<Banner variant="info" testId="custom-fields-form-create-mode">
|
|
47
|
-
<Text>
|
|
57
|
+
<Text>{t("custom-fields.form.createMode")}</Text>
|
|
48
58
|
</Banner>
|
|
49
59
|
);
|
|
50
60
|
}
|
|
51
61
|
if (query.loading && query.data === null) {
|
|
52
62
|
return (
|
|
53
63
|
<Banner variant="loading" testId="custom-fields-form-loading">
|
|
54
|
-
<Text>
|
|
64
|
+
<Text>{t("custom-fields.form.loading")}</Text>
|
|
55
65
|
</Banner>
|
|
56
66
|
);
|
|
57
67
|
}
|
|
58
68
|
if (query.error) {
|
|
59
69
|
return (
|
|
60
70
|
<Banner variant="error" testId="custom-fields-form-error">
|
|
61
|
-
<Text>{query.error.i18nKey}</Text>
|
|
71
|
+
<Text>{t(query.error.i18nKey, query.error.i18nParams)}</Text>
|
|
62
72
|
</Banner>
|
|
63
73
|
);
|
|
64
74
|
}
|
|
@@ -71,7 +81,7 @@ export function CustomFieldsFormSection({
|
|
|
71
81
|
if (matchingFields.length === 0) {
|
|
72
82
|
return (
|
|
73
83
|
<Banner variant="info" testId="custom-fields-form-empty">
|
|
74
|
-
<Text>
|
|
84
|
+
<Text>{t("custom-fields.form.empty", { entityName })}</Text>
|
|
75
85
|
</Banner>
|
|
76
86
|
);
|
|
77
87
|
}
|
|
@@ -91,7 +101,7 @@ export function CustomFieldsFormSection({
|
|
|
91
101
|
value,
|
|
92
102
|
});
|
|
93
103
|
if (!result.isSuccess) {
|
|
94
|
-
setErrorKey(result.error?.i18nKey ?? "custom-fields
|
|
104
|
+
setErrorKey(result.error?.i18nKey ?? "custom-fields.errors.saveFailed");
|
|
95
105
|
return;
|
|
96
106
|
}
|
|
97
107
|
}
|
|
@@ -123,11 +133,11 @@ export function CustomFieldsFormSection({
|
|
|
123
133
|
disabled={saving || !dirty}
|
|
124
134
|
testId="custom-fields-form-save"
|
|
125
135
|
>
|
|
126
|
-
{saving ? "
|
|
136
|
+
{saving ? t("custom-fields.form.saving") : t("custom-fields.form.save")}
|
|
127
137
|
</Button>
|
|
128
138
|
{errorKey !== null && (
|
|
129
139
|
<Banner variant="error" testId="custom-fields-form-save-error">
|
|
130
|
-
<Text>{errorKey}</Text>
|
|
140
|
+
<Text>{t(errorKey)}</Text>
|
|
131
141
|
</Banner>
|
|
132
142
|
)}
|
|
133
143
|
</div>
|