@cosmicdrift/kumiko-bundled-features 0.210.0 → 0.212.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 +8 -8
- package/src/admin-shell/feature.ts +2 -0
- package/src/admin-shell/web/platform-overview-screen.tsx +1 -0
- package/src/admin-shell/web/tenant-overview-screen.tsx +1 -0
- package/src/audit/__tests__/audit-log-screen.test.tsx +228 -0
- package/src/audit/constants.ts +3 -0
- package/src/audit/feature.ts +2 -0
- package/src/audit/i18n.ts +1 -0
- package/src/audit/web/audit-log-detail-screen.tsx +28 -6
- package/src/audit/web/audit-log-screen.tsx +51 -23
- package/src/auth-email-password/feature.ts +1 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +2 -0
- package/src/auth-email-password/web/auth-gate.tsx +1 -0
- package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +1 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +3 -0
- package/src/auth-email-password/web/session.tsx +1 -0
- package/src/auth-email-password/web/verify-email-screen.tsx +1 -0
- package/src/auth-mfa/feature.ts +1 -0
- package/src/auth-mfa/web/mfa-disable-dialog.tsx +1 -0
- package/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +1 -0
- package/src/compliance-profiles/feature.ts +1 -5
- package/src/crypto-shredding/changes.json +2 -1
- package/src/custom-fields/__tests__/feature.test.ts +42 -0
- package/src/custom-fields/__tests__/field-definition-role-config.integration.test.ts +139 -0
- package/src/custom-fields/constants.ts +6 -0
- package/src/custom-fields/feature.ts +56 -10
- package/src/custom-fields/handlers/define-tenant-field.write.ts +7 -1
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +54 -33
- package/src/custom-fields/handlers/update-tenant-field.write.ts +58 -35
- package/src/delivery/feature.ts +1 -3
- package/src/feature-toggles/feature.ts +1 -0
- package/src/feature-toggles/web/toggle-admin-screen.tsx +1 -0
- package/src/file-derivatives/changes.json +9 -1
- package/src/folders/web/folder-manager.tsx +1 -0
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +174 -0
- package/src/jobs/__tests__/jobs-stale-run-sweep.integration.test.ts +148 -0
- package/src/jobs/db/queries/stale-run-sweep.ts +51 -0
- package/src/jobs/feature.ts +25 -1
- package/src/jobs/handlers/detail.query.ts +13 -1
- package/src/jobs/handlers/stale-run-sweep.job.ts +28 -0
- package/src/jobs/job-run-logger.ts +68 -18
- package/src/jobs/web/job-run-detail-screen.tsx +1 -0
- package/src/page-render/web.ts +1 -0
- package/src/personal-access-tokens/changes.json +7 -0
- package/src/personal-access-tokens/feature.ts +1 -0
- package/src/secrets/__tests__/access-options.integration.test.ts +114 -0
- package/src/secrets/constants.ts +12 -0
- package/src/secrets/feature.ts +35 -9
- package/src/secrets/handlers/delete.write.ts +23 -16
- package/src/secrets/handlers/list.query.ts +37 -30
- package/src/secrets/handlers/set.write.ts +43 -36
- package/src/secrets/index.ts +5 -0
- package/src/seo/feature.ts +1 -0
- package/src/tags/feature.ts +1 -0
- package/src/tags/web/tag-chip.tsx +2 -0
- package/src/tags/web/tag-manager.tsx +2 -1
- package/src/tags/web/tag-picker.tsx +1 -0
- package/src/template-resolver/changes.json +9 -1
- package/src/template-resolver/web/client-plugin.tsx +2 -0
- package/src/tenant/__tests__/members-screens.boot.test.ts +79 -4
- package/src/tenant/__tests__/tenant-security.integration.test.ts +260 -3
- package/src/tenant/changes.json +6 -0
- package/src/tenant/constants.ts +17 -4
- package/src/tenant/feature.ts +28 -11
- package/src/tenant/handlers/team-list.query.ts +250 -0
- package/src/tenant/i18n.ts +20 -0
- package/src/tenant/screens.ts +111 -3
- package/src/tenant/web/client-plugin.tsx +11 -6
- package/src/tenant/web/index.ts +2 -2
- package/src/tenant/web/member-roles-cell.tsx +12 -0
- package/src/tenant/web/member-status-cell.tsx +23 -0
- package/src/tier-engine/feature.ts +2 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +5 -5
- package/src/user-data-rights/feature.ts +2 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
- package/src/user-profile/i18n.ts +1 -0
- package/src/user-profile/web/profile-screen.tsx +1 -1
- package/src/tenant/web/i18n.ts +0 -24
- package/src/tenant/web/members-screen.tsx +0 -270
package/src/auth-mfa/feature.ts
CHANGED
|
@@ -130,6 +130,7 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
|
|
|
130
130
|
// convention). App places it via r.nav in its logged-in settings area.
|
|
131
131
|
// dormant: true skips createKumikoApp's missing-client-plugin boot
|
|
132
132
|
// diagnostic (#2025) for apps that don't nav this screen (#2034).
|
|
133
|
+
// kumiko-lint-ignore app-feature-structure Multi-step auth flow (enable + recovery-codes reveal), no declarative screen type covers this
|
|
133
134
|
r.screen({
|
|
134
135
|
id: MFA_ENABLE_SCREEN_ID,
|
|
135
136
|
type: "custom",
|
|
@@ -21,6 +21,7 @@ export type MfaDisableDialogProps = {
|
|
|
21
21
|
readonly onError: (i18nKey: string) => void;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
// kumiko-lint-ignore no-custom-primitives Uses usePrimitives().Dialog internally, name is an MFA domain action, not a primitive reimplementation
|
|
24
25
|
export function MfaDisableDialog({
|
|
25
26
|
open,
|
|
26
27
|
onOpenChange,
|
|
@@ -18,6 +18,7 @@ export type MfaRegenerateRecoveryDialogProps = {
|
|
|
18
18
|
readonly onError: (i18nKey: string) => void;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
// kumiko-lint-ignore no-custom-primitives Uses usePrimitives().Dialog internally, name is an MFA domain action, not a primitive reimplementation
|
|
21
22
|
export function MfaRegenerateRecoveryDialog({
|
|
22
23
|
open,
|
|
23
24
|
onOpenChange,
|
|
@@ -103,11 +103,7 @@ export function createComplianceProfilesFeature(
|
|
|
103
103
|
id: "profile-picker",
|
|
104
104
|
label: "compliance-profiles:nav.profilePicker",
|
|
105
105
|
screen: "compliance-profiles:screen:profile-picker",
|
|
106
|
-
|
|
107
|
-
// vocabulary (packages/types/src/nav-icon.ts) — adding it needs a
|
|
108
|
-
// matching lucide-react entry in renderer-web's NAV_ICONS map too, both
|
|
109
|
-
// outside this feature. "shield" is the closest already-registered key.
|
|
110
|
-
icon: "shield",
|
|
106
|
+
icon: "shield-check",
|
|
111
107
|
order: 50,
|
|
112
108
|
});
|
|
113
109
|
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"type": "breaking",
|
|
17
17
|
"title": "Test-only reset helpers moved from /crypto to /testing (fw#1631).",
|
|
18
18
|
"detail": "resetPiiSubjectKmsForTests and resetBlindIndexKeyForTests are no longer exported by the production barrels. The functions did not change — only the export path. Why it matters beyond tidiness: resetPiiSubjectKmsForTests clears the injected KMS, after which encryptForStorage sees no adapter and writes subject-annotated fields in plaintext, with no error and no log. Reachable from a production barrel, that is one stray import away from silent plaintext PII.",
|
|
19
|
-
"migration": "Change the import in your test files: `import { resetPiiSubjectKmsForTests } from \"@cosmicdrift/kumiko-framework/testing\"` instead of `.../crypto`. Type-check catches every occurrence; nothing else changes. Apps mounting crypto-shredding typically hit this in every test that configures an InMemoryKmsAdapter."
|
|
19
|
+
"migration": "Change the import in your test files: `import { resetPiiSubjectKmsForTests } from \"@cosmicdrift/kumiko-framework/testing\"` instead of `.../crypto`. Type-check catches every occurrence; nothing else changes. Apps mounting crypto-shredding typically hit this in every test that configures an InMemoryKmsAdapter.",
|
|
20
|
+
"codemod": "scripts/codemod/crypto-shredding-testing-move.ts"
|
|
20
21
|
},
|
|
21
22
|
{
|
|
22
23
|
"version": "0.165.3",
|
|
@@ -189,6 +189,33 @@ describe("createCustomFieldsFeature access-options", () => {
|
|
|
189
189
|
});
|
|
190
190
|
expect(listAccess(feature)).toEqual(["Viewer"]);
|
|
191
191
|
});
|
|
192
|
+
|
|
193
|
+
// #2296 — define-/update-/delete-tenant-field were hard-wired to
|
|
194
|
+
// ["TenantAdmin"] with no override; fieldDefinitionWriteRoles closes that gap.
|
|
195
|
+
test("fieldDefinitionWriteRoles überschreibt define-, update- UND delete-tenant-field", () => {
|
|
196
|
+
const feature = createCustomFieldsFeature({ fieldDefinitionWriteRoles: ["Admin", "Editor"] });
|
|
197
|
+
expect(writeAccess(feature, "define-tenant-field")).toEqual(["Admin", "Editor"]);
|
|
198
|
+
expect(writeAccess(feature, "update-tenant-field")).toEqual(["Admin", "Editor"]);
|
|
199
|
+
expect(writeAccess(feature, "delete-tenant-field")).toEqual(["Admin", "Editor"]);
|
|
200
|
+
// System-scope stays hard-wired — a different trust boundary that must not
|
|
201
|
+
// become reachable through a tenant role vocabulary.
|
|
202
|
+
expect(writeAccess(feature, "define-system-field")).toEqual(["SystemAdmin"]);
|
|
203
|
+
expect(writeAccess(feature, "delete-system-field")).toEqual(["SystemAdmin"]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("fieldDefinitionWriteRoles feeds into the list-default (a definer can load what they defined)", () => {
|
|
207
|
+
const feature = createCustomFieldsFeature({ fieldDefinitionWriteRoles: ["Admin", "Editor"] });
|
|
208
|
+
const roles = listAccess(feature);
|
|
209
|
+
expect(roles).toContain("Admin");
|
|
210
|
+
expect(roles).toContain("Editor");
|
|
211
|
+
expect(roles).toContain("TenantAdmin");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("fieldDefinitionWriteRoles and valueWriteRoles are independent — setting one leaves the other's default", () => {
|
|
215
|
+
const feature = createCustomFieldsFeature({ fieldDefinitionWriteRoles: ["Admin"] });
|
|
216
|
+
expect(writeAccess(feature, "set-custom-field")).toEqual(["TenantAdmin", "TenantMember"]);
|
|
217
|
+
expect(writeAccess(feature, "clear-custom-field")).toEqual(["TenantAdmin", "TenantMember"]);
|
|
218
|
+
});
|
|
192
219
|
});
|
|
193
220
|
|
|
194
221
|
describe("resolveFieldDefinitionListRoles", () => {
|
|
@@ -216,4 +243,19 @@ describe("resolveFieldDefinitionListRoles", () => {
|
|
|
216
243
|
}),
|
|
217
244
|
).toEqual(["Viewer"]);
|
|
218
245
|
});
|
|
246
|
+
|
|
247
|
+
test("fieldDefinitionWriteRoles gesetzt, list ungesetzt → Union mit Default, dedupliziert", () => {
|
|
248
|
+
expect(
|
|
249
|
+
resolveFieldDefinitionListRoles({ fieldDefinitionWriteRoles: ["Admin", "Editor"] }),
|
|
250
|
+
).toEqual(["Admin", "Editor", "TenantAdmin"]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("valueWriteRoles UND fieldDefinitionWriteRoles gesetzt → Union aus beiden plus Default", () => {
|
|
254
|
+
expect(
|
|
255
|
+
resolveFieldDefinitionListRoles({
|
|
256
|
+
valueWriteRoles: ["Member"],
|
|
257
|
+
fieldDefinitionWriteRoles: ["Admin"],
|
|
258
|
+
}),
|
|
259
|
+
).toEqual(["Member", "Admin", "TenantAdmin"]);
|
|
260
|
+
});
|
|
219
261
|
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// #2296 — role-configurability of the definition-CRUD triad (define-/update-/
|
|
2
|
+
// delete-tenant-field), which was hard-wired to ["TenantAdmin"] with no override.
|
|
3
|
+
// Verifies createCustomFieldsFeature({ fieldDefinitionWriteRoles }) actually
|
|
4
|
+
// gates all three handlers with the configured roles (not just define), that the
|
|
5
|
+
// default-role user loses access once the override is set (Role-Naming-Drift is
|
|
6
|
+
// real, not just documented), and that a definer without an explicit
|
|
7
|
+
// fieldDefinitionListRoles override can still list what they defined.
|
|
8
|
+
|
|
9
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
11
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
12
|
+
import {
|
|
13
|
+
createTestUser,
|
|
14
|
+
setupTestStack,
|
|
15
|
+
type TestStack,
|
|
16
|
+
unsafeCreateEntityTable,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
18
|
+
import { fieldDefinitionEntity } from "../entity";
|
|
19
|
+
import { createCustomFieldsFeature } from "../feature";
|
|
20
|
+
|
|
21
|
+
const admin = createTestUser({ roles: ["TenantAdmin"] });
|
|
22
|
+
const customRoleUser = createTestUser({ roles: ["Admin"] });
|
|
23
|
+
|
|
24
|
+
function defPayload(fieldKey: string) {
|
|
25
|
+
return {
|
|
26
|
+
entityName: "customer",
|
|
27
|
+
fieldKey,
|
|
28
|
+
serializedField: { type: "text" },
|
|
29
|
+
required: false,
|
|
30
|
+
searchable: false,
|
|
31
|
+
displayOrder: 0,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("custom-fields — fieldDefinitionWriteRoles (#2296)", () => {
|
|
36
|
+
let stack: TestStack;
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
stack = await setupTestStack({
|
|
40
|
+
features: [createCustomFieldsFeature({ fieldDefinitionWriteRoles: ["Admin"] })],
|
|
41
|
+
});
|
|
42
|
+
await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
|
|
43
|
+
await createEventsTable(stack.db);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(async () => {
|
|
47
|
+
await stack.cleanup();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
beforeEach(async () => {
|
|
51
|
+
await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
|
|
52
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_custom_field_definitions");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a custom-role user can define, update and delete a tenant field definition", async () => {
|
|
56
|
+
await stack.http.writeOk(
|
|
57
|
+
"custom-fields:write:define-tenant-field",
|
|
58
|
+
defPayload("vip"),
|
|
59
|
+
customRoleUser,
|
|
60
|
+
);
|
|
61
|
+
const afterDefine = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
|
|
62
|
+
"custom-fields:query:field-definition:list",
|
|
63
|
+
{},
|
|
64
|
+
customRoleUser,
|
|
65
|
+
);
|
|
66
|
+
const defined = afterDefine.rows.find((r) => r["fieldKey"] === "vip");
|
|
67
|
+
expect(defined).toBeDefined();
|
|
68
|
+
expect(defined?.["searchable"]).toBe(false);
|
|
69
|
+
|
|
70
|
+
await stack.http.writeOk(
|
|
71
|
+
"custom-fields:write:update-tenant-field",
|
|
72
|
+
{ ...defPayload("vip"), searchable: true },
|
|
73
|
+
customRoleUser,
|
|
74
|
+
);
|
|
75
|
+
const afterUpdate = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
|
|
76
|
+
"custom-fields:query:field-definition:list",
|
|
77
|
+
{},
|
|
78
|
+
customRoleUser,
|
|
79
|
+
);
|
|
80
|
+
const updated = afterUpdate.rows.find((r) => r["fieldKey"] === "vip");
|
|
81
|
+
expect(updated?.["searchable"]).toBe(true);
|
|
82
|
+
|
|
83
|
+
await stack.http.writeOk(
|
|
84
|
+
"custom-fields:write:delete-tenant-field",
|
|
85
|
+
{ entityName: "customer", fieldKey: "vip" },
|
|
86
|
+
customRoleUser,
|
|
87
|
+
);
|
|
88
|
+
const afterDelete = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
|
|
89
|
+
"custom-fields:query:field-definition:list",
|
|
90
|
+
{},
|
|
91
|
+
customRoleUser,
|
|
92
|
+
);
|
|
93
|
+
expect(afterDelete.rows.some((r) => r["fieldKey"] === "vip")).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("the default TenantAdmin role is denied on all three once fieldDefinitionWriteRoles overrides it", async () => {
|
|
97
|
+
const defineDenied = await stack.http.writeErr(
|
|
98
|
+
"custom-fields:write:define-tenant-field",
|
|
99
|
+
defPayload("vip2"),
|
|
100
|
+
admin,
|
|
101
|
+
);
|
|
102
|
+
expect(defineDenied.httpStatus).toBe(403);
|
|
103
|
+
|
|
104
|
+
await stack.http.writeOk(
|
|
105
|
+
"custom-fields:write:define-tenant-field",
|
|
106
|
+
defPayload("vip2"),
|
|
107
|
+
customRoleUser,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const updateDenied = await stack.http.writeErr(
|
|
111
|
+
"custom-fields:write:update-tenant-field",
|
|
112
|
+
{ ...defPayload("vip2"), searchable: true },
|
|
113
|
+
admin,
|
|
114
|
+
);
|
|
115
|
+
expect(updateDenied.httpStatus).toBe(403);
|
|
116
|
+
|
|
117
|
+
const deleteDenied = await stack.http.writeErr(
|
|
118
|
+
"custom-fields:write:delete-tenant-field",
|
|
119
|
+
{ entityName: "customer", fieldKey: "vip2" },
|
|
120
|
+
admin,
|
|
121
|
+
);
|
|
122
|
+
expect(deleteDenied.httpStatus).toBe(403);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a definer without an explicit fieldDefinitionListRoles override can list their own definitions", async () => {
|
|
126
|
+
await stack.http.writeOk(
|
|
127
|
+
"custom-fields:write:define-tenant-field",
|
|
128
|
+
defPayload("vip3"),
|
|
129
|
+
customRoleUser,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const list = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
|
|
133
|
+
"custom-fields:query:field-definition:list",
|
|
134
|
+
{},
|
|
135
|
+
customRoleUser,
|
|
136
|
+
);
|
|
137
|
+
expect(list.rows.some((r) => r["fieldKey"] === "vip3")).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -59,6 +59,12 @@ export const CUSTOM_FIELDS_EXTENSION = "customFields";
|
|
|
59
59
|
// für jeden App-User access_denied.
|
|
60
60
|
export const DEFAULT_VALUE_WRITE_ROLES = ["TenantAdmin", "TenantMember"] as const;
|
|
61
61
|
export const DEFAULT_FIELD_DEFINITION_LIST_ROLES = ["TenantAdmin"] as const;
|
|
62
|
+
// Default-RBAC of the definition-CRUD triad (define-/update-/delete-tenant-field,
|
|
63
|
+
// #2296). Apps with their own role vocabulary override via
|
|
64
|
+
// createCustomFieldsFeature({ fieldDefinitionWriteRoles }) — otherwise those three
|
|
65
|
+
// hard-wired handler QNs are access_denied for every app-user without a
|
|
66
|
+
// "TenantAdmin" role (same Role-Naming-Drift as DEFAULT_VALUE_WRITE_ROLES above).
|
|
67
|
+
export const DEFAULT_FIELD_DEFINITION_WRITE_ROLES = ["TenantAdmin"] as const;
|
|
62
68
|
|
|
63
69
|
// Field-type union — identisch zu Stammfeld-Field-Type-System (Spec Z.59-73:
|
|
64
70
|
// `Identisch zu Entity-Feld-Typen`). Builder-Reuse-Promise: was `r.field.X()`
|
|
@@ -73,12 +73,18 @@ import {
|
|
|
73
73
|
defineTenantFieldHandler,
|
|
74
74
|
} from "./handlers/define-tenant-field.write";
|
|
75
75
|
import { deleteSystemFieldHandler } from "./handlers/delete-system-field.write";
|
|
76
|
-
import {
|
|
76
|
+
import {
|
|
77
|
+
createDeleteTenantFieldHandler,
|
|
78
|
+
deleteTenantFieldHandler,
|
|
79
|
+
} from "./handlers/delete-tenant-field.write";
|
|
77
80
|
import {
|
|
78
81
|
createSetCustomFieldHandler,
|
|
79
82
|
setCustomFieldHandler,
|
|
80
83
|
} from "./handlers/set-custom-field.write";
|
|
81
|
-
import {
|
|
84
|
+
import {
|
|
85
|
+
createUpdateTenantFieldHandler,
|
|
86
|
+
updateTenantFieldHandler,
|
|
87
|
+
} from "./handlers/update-tenant-field.write";
|
|
82
88
|
|
|
83
89
|
const fieldDefinitionDeletedSchema = z.object({
|
|
84
90
|
entityName: z.string(),
|
|
@@ -97,6 +103,8 @@ const fieldDefinitionDeletedSchema = z.object({
|
|
|
97
103
|
// Options-Variante verfehlt.
|
|
98
104
|
type RegisterVariant = {
|
|
99
105
|
readonly defineTenantHandler: WriteHandlerDef;
|
|
106
|
+
readonly updateTenantHandler: WriteHandlerDef;
|
|
107
|
+
readonly deleteTenantHandler: WriteHandlerDef;
|
|
100
108
|
readonly setHandler: WriteHandlerDef;
|
|
101
109
|
readonly clearHandler: WriteHandlerDef;
|
|
102
110
|
readonly fieldDefinitionListRoles: readonly string[];
|
|
@@ -141,8 +149,8 @@ function registerCustomFields(
|
|
|
141
149
|
// Definition-CRUD handlers (B1; update kam mit Bug-Bash D2 2026-06-08).
|
|
142
150
|
r.writeHandler(variant.defineTenantHandler);
|
|
143
151
|
r.writeHandler(defineSystemFieldHandler);
|
|
144
|
-
r.writeHandler(
|
|
145
|
-
r.writeHandler(
|
|
152
|
+
r.writeHandler(variant.updateTenantHandler);
|
|
153
|
+
r.writeHandler(variant.deleteTenantHandler);
|
|
146
154
|
r.writeHandler(deleteSystemFieldHandler);
|
|
147
155
|
|
|
148
156
|
// Value-write handlers (B2). Emittieren events auf host-aggregate-stream.
|
|
@@ -169,6 +177,8 @@ function registerCustomFields(
|
|
|
169
177
|
export const customFieldsFeature = defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) =>
|
|
170
178
|
registerCustomFields(r, {
|
|
171
179
|
defineTenantHandler: defineTenantFieldHandler,
|
|
180
|
+
updateTenantHandler: updateTenantFieldHandler,
|
|
181
|
+
deleteTenantHandler: deleteTenantFieldHandler,
|
|
172
182
|
setHandler: setCustomFieldHandler,
|
|
173
183
|
clearHandler: clearCustomFieldHandler,
|
|
174
184
|
fieldDefinitionListRoles: DEFAULT_FIELD_DEFINITION_LIST_ROLES,
|
|
@@ -191,14 +201,37 @@ export type CustomFieldsFeatureOptions = {
|
|
|
191
201
|
* FormSection für Value-Writer nie (access_denied), während der Save-Pfad
|
|
192
202
|
* offen wäre (asymmetrischer Bruch). */
|
|
193
203
|
readonly fieldDefinitionListRoles?: readonly string[];
|
|
204
|
+
/** Access roles for the definition-CRUD triad (`define-tenant-field`,
|
|
205
|
+
* `update-tenant-field`, `delete-tenant-field`) — hard-wired to ["TenantAdmin"]
|
|
206
|
+
* before #2296. Apps with their own role vocabulary (e.g. ["Admin","Editor"])
|
|
207
|
+
* MUST set this, else field-definition management is access_denied for every
|
|
208
|
+
* app-user without a "TenantAdmin" role. Only covers the tenant-scope triad —
|
|
209
|
+
* define-/delete-system-field stay hard-wired to ["TenantAdmin"], since
|
|
210
|
+
* system-scope is a separate trust boundary that must not become reachable
|
|
211
|
+
* through a tenant role vocabulary. Also feeds into
|
|
212
|
+
* resolveFieldDefinitionListRoles (below) so a definer without an explicit
|
|
213
|
+
* fieldDefinitionListRoles override can still load the definitions they can
|
|
214
|
+
* write — mirrors the valueWriteRoles → list-default inheritance. */
|
|
215
|
+
readonly fieldDefinitionWriteRoles?: readonly string[];
|
|
194
216
|
};
|
|
195
217
|
|
|
196
218
|
export function resolveFieldDefinitionListRoles(
|
|
197
|
-
opts: Pick<
|
|
219
|
+
opts: Pick<
|
|
220
|
+
CustomFieldsFeatureOptions,
|
|
221
|
+
"valueWriteRoles" | "fieldDefinitionListRoles" | "fieldDefinitionWriteRoles"
|
|
222
|
+
>,
|
|
198
223
|
): readonly string[] {
|
|
199
224
|
if (opts.fieldDefinitionListRoles !== undefined) return opts.fieldDefinitionListRoles;
|
|
200
|
-
if (opts.valueWriteRoles === undefined)
|
|
201
|
-
|
|
225
|
+
if (opts.valueWriteRoles === undefined && opts.fieldDefinitionWriteRoles === undefined) {
|
|
226
|
+
return DEFAULT_FIELD_DEFINITION_LIST_ROLES;
|
|
227
|
+
}
|
|
228
|
+
return [
|
|
229
|
+
...new Set([
|
|
230
|
+
...(opts.valueWriteRoles ?? []),
|
|
231
|
+
...(opts.fieldDefinitionWriteRoles ?? []),
|
|
232
|
+
...DEFAULT_FIELD_DEFINITION_LIST_ROLES,
|
|
233
|
+
]),
|
|
234
|
+
];
|
|
202
235
|
}
|
|
203
236
|
|
|
204
237
|
// Backwards-compat-wrapper. Bestehende Caller (z.B. integration-tests,
|
|
@@ -212,17 +245,30 @@ export function createCustomFieldsFeature(
|
|
|
212
245
|
const hasOptions =
|
|
213
246
|
opts.fieldDefinitionLimitPerTenant !== undefined ||
|
|
214
247
|
opts.valueWriteRoles !== undefined ||
|
|
215
|
-
opts.fieldDefinitionListRoles !== undefined
|
|
248
|
+
opts.fieldDefinitionListRoles !== undefined ||
|
|
249
|
+
opts.fieldDefinitionWriteRoles !== undefined;
|
|
216
250
|
if (!hasOptions) {
|
|
217
251
|
return customFieldsFeature;
|
|
218
252
|
}
|
|
219
253
|
const limit = opts.fieldDefinitionLimitPerTenant;
|
|
254
|
+
const definitionWriteRoles = opts.fieldDefinitionWriteRoles;
|
|
220
255
|
return defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) =>
|
|
221
256
|
registerCustomFields(r, {
|
|
222
257
|
defineTenantHandler:
|
|
223
|
-
limit !== undefined
|
|
224
|
-
? createDefineTenantFieldHandler({
|
|
258
|
+
limit !== undefined || definitionWriteRoles !== undefined
|
|
259
|
+
? createDefineTenantFieldHandler({
|
|
260
|
+
fieldDefinitionLimitPerTenant: limit,
|
|
261
|
+
roles: definitionWriteRoles,
|
|
262
|
+
})
|
|
225
263
|
: defineTenantFieldHandler,
|
|
264
|
+
updateTenantHandler:
|
|
265
|
+
definitionWriteRoles !== undefined
|
|
266
|
+
? createUpdateTenantFieldHandler({ roles: definitionWriteRoles })
|
|
267
|
+
: updateTenantFieldHandler,
|
|
268
|
+
deleteTenantHandler:
|
|
269
|
+
definitionWriteRoles !== undefined
|
|
270
|
+
? createDeleteTenantFieldHandler({ roles: definitionWriteRoles })
|
|
271
|
+
: deleteTenantFieldHandler,
|
|
226
272
|
setHandler: createSetCustomFieldHandler(opts.valueWriteRoles),
|
|
227
273
|
clearHandler: createClearCustomFieldHandler(opts.valueWriteRoles),
|
|
228
274
|
fieldDefinitionListRoles: resolveFieldDefinitionListRoles(opts),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isSystemTenant, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
4
|
+
import { DEFAULT_FIELD_DEFINITION_WRITE_ROLES } from "../constants";
|
|
4
5
|
import { defineOrResurrectFieldDefinition } from "../lib/define-or-resurrect";
|
|
5
6
|
import { buildFieldDefinitionColumns } from "../lib/field-definition-row";
|
|
6
7
|
import { countTenantFieldDefinitions } from "../lib/quota";
|
|
@@ -37,6 +38,11 @@ import { type DefineFieldPayload, defineFieldPayloadSchema } from "../schemas";
|
|
|
37
38
|
export interface DefineTenantFieldOptions {
|
|
38
39
|
/** Soft cap — `>= limit` definitions per tenant rejects further defines (see header: concurrent defines may overshoot). */
|
|
39
40
|
readonly fieldDefinitionLimitPerTenant?: number;
|
|
41
|
+
/** Access roles for this handler. Default ["TenantAdmin"] (#2296) — apps with
|
|
42
|
+
* their own role vocabulary pass this via createCustomFieldsFeature({
|
|
43
|
+
* fieldDefinitionWriteRoles }), which threads the same roles into
|
|
44
|
+
* update-/delete-tenant-field too (the definition-CRUD triad shares one gate). */
|
|
45
|
+
readonly roles?: readonly string[];
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
export function createDefineTenantFieldHandler(
|
|
@@ -46,7 +52,7 @@ export function createDefineTenantFieldHandler(
|
|
|
46
52
|
return {
|
|
47
53
|
name: "define-tenant-field",
|
|
48
54
|
schema: defineFieldPayloadSchema,
|
|
49
|
-
access: { roles:
|
|
55
|
+
access: { roles: opts.roles ?? DEFAULT_FIELD_DEFINITION_WRITE_ROLES },
|
|
50
56
|
handler: async (event, ctx) => {
|
|
51
57
|
const payload = event.payload as DefineFieldPayload; // @cast-boundary engine-payload
|
|
52
58
|
const tenantId = event.user.tenantId;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { isSystemTenant, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_FIELD_DEFINITION_WRITE_ROLES,
|
|
5
|
+
FIELD_DEFINITION_AGGREGATE_TYPE,
|
|
6
|
+
} from "../constants";
|
|
4
7
|
import { fieldDefinitionExecutor } from "../executor";
|
|
5
8
|
import { customFieldsFeature } from "../feature";
|
|
6
9
|
import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
|
|
@@ -15,36 +18,54 @@ import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
|
|
|
15
18
|
// **Idempotency:** Delete auf nicht-existente Definition → version_conflict
|
|
16
19
|
// (executor.delete returns failure, dispatcher 404/422). Caller sieht "not
|
|
17
20
|
// found" — kein Crash.
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
21
|
+
export interface DeleteTenantFieldOptions {
|
|
22
|
+
/** Access roles for this handler. Default ["TenantAdmin"] (#2296) — see
|
|
23
|
+
* DefineTenantFieldOptions.roles, the definition-CRUD triad shares one gate. */
|
|
24
|
+
readonly roles?: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createDeleteTenantFieldHandler(
|
|
28
|
+
opts: DeleteTenantFieldOptions = {},
|
|
29
|
+
): WriteHandlerDef {
|
|
30
|
+
return {
|
|
31
|
+
name: "delete-tenant-field",
|
|
32
|
+
schema: deleteFieldPayloadSchema,
|
|
33
|
+
access: { roles: opts.roles ?? DEFAULT_FIELD_DEFINITION_WRITE_ROLES },
|
|
34
|
+
handler: async (event, ctx) => {
|
|
35
|
+
const payload = event.payload as DeleteFieldPayload; // @cast-boundary engine-payload
|
|
36
|
+
const tenantId = event.user.tenantId;
|
|
37
|
+
|
|
38
|
+
if (isSystemTenant(tenantId)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
"delete-tenant-field: tenantId is SYSTEM_TENANT_ID — use delete-system-field for system-scope deletions",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const aggregateId = fieldDefinitionAggregateId(
|
|
45
|
+
tenantId,
|
|
46
|
+
payload.entityName,
|
|
47
|
+
payload.fieldKey,
|
|
29
48
|
);
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
|
|
50
|
+
const result = await fieldDefinitionExecutor.delete({ id: aggregateId }, event.user, ctx.db);
|
|
51
|
+
|
|
52
|
+
// Emit cascade-cleanup-Event NACH erfolgreichem Delete. host-entity-MSPs
|
|
53
|
+
// (registriert via wireCustomFieldsFor) konsumieren das + entfernen orphan
|
|
54
|
+
// values aus ihrer customFields jsonb. Im selben TX = atomic cleanup.
|
|
55
|
+
if (result.isSuccess) {
|
|
56
|
+
await ctx.unsafeAppendEvent({
|
|
57
|
+
aggregateId,
|
|
58
|
+
aggregateType: FIELD_DEFINITION_AGGREGATE_TYPE,
|
|
59
|
+
type: customFieldsFeature.exports.fieldDefinitionDeletedEvent.name,
|
|
60
|
+
payload: { entityName: payload.entityName, fieldKey: payload.fieldKey, tenantId },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return result;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Backwards-compat: existing imports of `deleteTenantFieldHandler` keep
|
|
70
|
+
// working — identical to createDeleteTenantFieldHandler() with default roles.
|
|
71
|
+
export const deleteTenantFieldHandler: WriteHandlerDef = createDeleteTenantFieldHandler();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isSystemTenant, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
4
|
+
import { DEFAULT_FIELD_DEFINITION_WRITE_ROLES } from "../constants";
|
|
4
5
|
import { fieldDefinitionExecutor } from "../executor";
|
|
5
6
|
import { buildFieldDefinitionColumns } from "../lib/field-definition-row";
|
|
6
7
|
import { type UpdateFieldPayload, updateFieldPayloadSchema } from "../schemas";
|
|
@@ -29,45 +30,67 @@ import { type UpdateFieldPayload, updateFieldPayloadSchema } from "../schemas";
|
|
|
29
30
|
// **skipOptimisticLock:** Definition-Edits sind admin-only + low-frequency
|
|
30
31
|
// (gleiche Abwägung wie der Quota-soft-cap in define). Last-write-wins
|
|
31
32
|
// statt version-Roundtrip durch den Edit-Screen.
|
|
32
|
-
export
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const payload = event.payload as UpdateFieldPayload; // @cast-boundary engine-payload
|
|
38
|
-
const tenantId = event.user.tenantId;
|
|
33
|
+
export interface UpdateTenantFieldOptions {
|
|
34
|
+
/** Access roles for this handler. Default ["TenantAdmin"] (#2296) — see
|
|
35
|
+
* DefineTenantFieldOptions.roles, the definition-CRUD triad shares one gate. */
|
|
36
|
+
readonly roles?: readonly string[];
|
|
37
|
+
}
|
|
39
38
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
export function createUpdateTenantFieldHandler(
|
|
40
|
+
opts: UpdateTenantFieldOptions = {},
|
|
41
|
+
): WriteHandlerDef {
|
|
42
|
+
return {
|
|
43
|
+
name: "update-tenant-field",
|
|
44
|
+
schema: updateFieldPayloadSchema,
|
|
45
|
+
access: { roles: opts.roles ?? DEFAULT_FIELD_DEFINITION_WRITE_ROLES },
|
|
46
|
+
handler: async (event, ctx) => {
|
|
47
|
+
const payload = event.payload as UpdateFieldPayload; // @cast-boundary engine-payload
|
|
48
|
+
const tenantId = event.user.tenantId;
|
|
49
|
+
|
|
50
|
+
if (isSystemTenant(tenantId)) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
"update-tenant-field: tenantId is SYSTEM_TENANT_ID — system-scope definitions have no update handler (delete + re-define via the system-field handlers)",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const aggregateId = fieldDefinitionAggregateId(
|
|
57
|
+
tenantId,
|
|
58
|
+
payload.entityName,
|
|
59
|
+
payload.fieldKey,
|
|
43
60
|
);
|
|
44
|
-
}
|
|
45
61
|
|
|
46
|
-
|
|
62
|
+
const existing = await fieldDefinitionExecutor.detail(
|
|
63
|
+
{ id: aggregateId },
|
|
64
|
+
event.user,
|
|
65
|
+
ctx.db,
|
|
66
|
+
);
|
|
67
|
+
if (!existing) {
|
|
68
|
+
return failNotFound("field-definition", aggregateId);
|
|
69
|
+
}
|
|
47
70
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
71
|
+
if (existing["type"] !== payload.serializedField.type) {
|
|
72
|
+
return failUnprocessable("field_type_immutable", {
|
|
73
|
+
entityName: payload.entityName,
|
|
74
|
+
fieldKey: payload.fieldKey,
|
|
75
|
+
currentType: existing["type"],
|
|
76
|
+
requestedType: payload.serializedField.type,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
52
79
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
entityName:
|
|
56
|
-
fieldKey:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
});
|
|
60
|
-
}
|
|
80
|
+
// entityName/fieldKey sind die Identität — nicht Teil der changes.
|
|
81
|
+
const {
|
|
82
|
+
entityName: _entityName,
|
|
83
|
+
fieldKey: _fieldKey,
|
|
84
|
+
...changes
|
|
85
|
+
} = buildFieldDefinitionColumns(payload);
|
|
61
86
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
87
|
+
return fieldDefinitionExecutor.update({ id: aggregateId, changes }, event.user, ctx.db, {
|
|
88
|
+
skipOptimisticLock: true,
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
68
93
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
},
|
|
73
|
-
};
|
|
94
|
+
// Backwards-compat: existing imports of `updateTenantFieldHandler` keep
|
|
95
|
+
// working — identical to createUpdateTenantFieldHandler() with default roles.
|
|
96
|
+
export const updateTenantFieldHandler: WriteHandlerDef = createUpdateTenantFieldHandler();
|
package/src/delivery/feature.ts
CHANGED
|
@@ -163,9 +163,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
163
163
|
r.nav({
|
|
164
164
|
id: "delivery-log",
|
|
165
165
|
label: "delivery:nav.deliveryLog",
|
|
166
|
-
|
|
167
|
-
// nav-icon.ts) — "bell" matches the feature's own notifications framing.
|
|
168
|
-
icon: "bell",
|
|
166
|
+
icon: "send",
|
|
169
167
|
screen: "delivery:screen:delivery-log",
|
|
170
168
|
order: 40,
|
|
171
169
|
});
|