@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.
Files changed (79) hide show
  1. package/package.json +8 -8
  2. package/src/admin-shell/feature.ts +2 -0
  3. package/src/admin-shell/web/platform-overview-screen.tsx +1 -0
  4. package/src/admin-shell/web/tenant-overview-screen.tsx +1 -0
  5. package/src/audit/__tests__/audit-log-screen.test.tsx +228 -0
  6. package/src/audit/constants.ts +3 -0
  7. package/src/audit/feature.ts +2 -0
  8. package/src/audit/i18n.ts +1 -0
  9. package/src/audit/web/audit-log-detail-screen.tsx +28 -6
  10. package/src/audit/web/audit-log-screen.tsx +51 -23
  11. package/src/auth-email-password/feature.ts +1 -0
  12. package/src/auth-email-password/web/auth-form-primitives.tsx +2 -0
  13. package/src/auth-email-password/web/auth-gate.tsx +1 -0
  14. package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +1 -0
  15. package/src/auth-email-password/web/invite-accept-screen.tsx +3 -0
  16. package/src/auth-email-password/web/session.tsx +1 -0
  17. package/src/auth-email-password/web/verify-email-screen.tsx +1 -0
  18. package/src/auth-mfa/feature.ts +1 -0
  19. package/src/auth-mfa/web/mfa-disable-dialog.tsx +1 -0
  20. package/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +1 -0
  21. package/src/compliance-profiles/feature.ts +1 -5
  22. package/src/crypto-shredding/changes.json +2 -1
  23. package/src/custom-fields/__tests__/feature.test.ts +42 -0
  24. package/src/custom-fields/__tests__/field-definition-role-config.integration.test.ts +139 -0
  25. package/src/custom-fields/constants.ts +6 -0
  26. package/src/custom-fields/feature.ts +56 -10
  27. package/src/custom-fields/handlers/define-tenant-field.write.ts +7 -1
  28. package/src/custom-fields/handlers/delete-tenant-field.write.ts +54 -33
  29. package/src/custom-fields/handlers/update-tenant-field.write.ts +58 -35
  30. package/src/delivery/feature.ts +1 -3
  31. package/src/feature-toggles/feature.ts +1 -0
  32. package/src/feature-toggles/web/toggle-admin-screen.tsx +1 -0
  33. package/src/file-derivatives/changes.json +9 -1
  34. package/src/folders/web/folder-manager.tsx +1 -0
  35. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +174 -0
  36. package/src/jobs/__tests__/jobs-stale-run-sweep.integration.test.ts +148 -0
  37. package/src/jobs/db/queries/stale-run-sweep.ts +51 -0
  38. package/src/jobs/feature.ts +25 -1
  39. package/src/jobs/handlers/detail.query.ts +13 -1
  40. package/src/jobs/handlers/stale-run-sweep.job.ts +28 -0
  41. package/src/jobs/job-run-logger.ts +68 -18
  42. package/src/jobs/web/job-run-detail-screen.tsx +1 -0
  43. package/src/page-render/web.ts +1 -0
  44. package/src/personal-access-tokens/changes.json +7 -0
  45. package/src/personal-access-tokens/feature.ts +1 -0
  46. package/src/secrets/__tests__/access-options.integration.test.ts +114 -0
  47. package/src/secrets/constants.ts +12 -0
  48. package/src/secrets/feature.ts +35 -9
  49. package/src/secrets/handlers/delete.write.ts +23 -16
  50. package/src/secrets/handlers/list.query.ts +37 -30
  51. package/src/secrets/handlers/set.write.ts +43 -36
  52. package/src/secrets/index.ts +5 -0
  53. package/src/seo/feature.ts +1 -0
  54. package/src/tags/feature.ts +1 -0
  55. package/src/tags/web/tag-chip.tsx +2 -0
  56. package/src/tags/web/tag-manager.tsx +2 -1
  57. package/src/tags/web/tag-picker.tsx +1 -0
  58. package/src/template-resolver/changes.json +9 -1
  59. package/src/template-resolver/web/client-plugin.tsx +2 -0
  60. package/src/tenant/__tests__/members-screens.boot.test.ts +79 -4
  61. package/src/tenant/__tests__/tenant-security.integration.test.ts +260 -3
  62. package/src/tenant/changes.json +6 -0
  63. package/src/tenant/constants.ts +17 -4
  64. package/src/tenant/feature.ts +28 -11
  65. package/src/tenant/handlers/team-list.query.ts +250 -0
  66. package/src/tenant/i18n.ts +20 -0
  67. package/src/tenant/screens.ts +111 -3
  68. package/src/tenant/web/client-plugin.tsx +11 -6
  69. package/src/tenant/web/index.ts +2 -2
  70. package/src/tenant/web/member-roles-cell.tsx +12 -0
  71. package/src/tenant/web/member-status-cell.tsx +23 -0
  72. package/src/tier-engine/feature.ts +2 -0
  73. package/src/tier-engine/web/tier-admin-screen.tsx +5 -5
  74. package/src/user-data-rights/feature.ts +2 -0
  75. package/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
  76. package/src/user-profile/i18n.ts +1 -0
  77. package/src/user-profile/web/profile-screen.tsx +1 -1
  78. package/src/tenant/web/i18n.ts +0 -24
  79. package/src/tenant/web/members-screen.tsx +0 -270
@@ -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
- // "shield-check" (spec suggestion, fw#2222) isn't in NavIconKey's closed
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 { deleteTenantFieldHandler } from "./handlers/delete-tenant-field.write";
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 { updateTenantFieldHandler } from "./handlers/update-tenant-field.write";
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(updateTenantFieldHandler);
145
- r.writeHandler(deleteTenantFieldHandler);
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<CustomFieldsFeatureOptions, "valueWriteRoles" | "fieldDefinitionListRoles">,
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) return DEFAULT_FIELD_DEFINITION_LIST_ROLES;
201
- return [...new Set([...opts.valueWriteRoles, ...DEFAULT_FIELD_DEFINITION_LIST_ROLES])];
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({ fieldDefinitionLimitPerTenant: limit })
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: ["TenantAdmin"] },
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 { FIELD_DEFINITION_AGGREGATE_TYPE } from "../constants";
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 const deleteTenantFieldHandler: WriteHandlerDef = {
19
- name: "delete-tenant-field",
20
- schema: deleteFieldPayloadSchema,
21
- access: { roles: ["TenantAdmin"] },
22
- handler: async (event, ctx) => {
23
- const payload = event.payload as DeleteFieldPayload; // @cast-boundary engine-payload
24
- const tenantId = event.user.tenantId;
25
-
26
- if (isSystemTenant(tenantId)) {
27
- throw new Error(
28
- "delete-tenant-field: tenantId is SYSTEM_TENANT_ID — use delete-system-field for system-scope deletions",
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
- const aggregateId = fieldDefinitionAggregateId(tenantId, payload.entityName, payload.fieldKey);
33
-
34
- const result = await fieldDefinitionExecutor.delete({ id: aggregateId }, event.user, ctx.db);
35
-
36
- // Emit cascade-cleanup-Event NACH erfolgreichem Delete. host-entity-MSPs
37
- // (registriert via wireCustomFieldsFor) konsumieren das + entfernen orphan
38
- // values aus ihrer customFields jsonb. Im selben TX = atomic cleanup.
39
- if (result.isSuccess) {
40
- await ctx.unsafeAppendEvent({
41
- aggregateId,
42
- aggregateType: FIELD_DEFINITION_AGGREGATE_TYPE,
43
- type: customFieldsFeature.exports.fieldDefinitionDeletedEvent.name,
44
- payload: { entityName: payload.entityName, fieldKey: payload.fieldKey, tenantId },
45
- });
46
- }
47
-
48
- return result;
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 const updateTenantFieldHandler: WriteHandlerDef = {
33
- name: "update-tenant-field",
34
- schema: updateFieldPayloadSchema,
35
- access: { roles: ["TenantAdmin"] },
36
- handler: async (event, ctx) => {
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
- if (isSystemTenant(tenantId)) {
41
- throw new Error(
42
- "update-tenant-field: tenantId is SYSTEM_TENANT_ID — system-scope definitions have no update handler (delete + re-define via the system-field handlers)",
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
- const aggregateId = fieldDefinitionAggregateId(tenantId, payload.entityName, payload.fieldKey);
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
- const existing = await fieldDefinitionExecutor.detail({ id: aggregateId }, event.user, ctx.db);
49
- if (!existing) {
50
- return failNotFound("field-definition", aggregateId);
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
- if (existing["type"] !== payload.serializedField.type) {
54
- return failUnprocessable("field_type_immutable", {
55
- entityName: payload.entityName,
56
- fieldKey: payload.fieldKey,
57
- currentType: existing["type"],
58
- requestedType: payload.serializedField.type,
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
- // entityName/fieldKey sind die Identität — nicht Teil der changes.
63
- const {
64
- entityName: _entityName,
65
- fieldKey: _fieldKey,
66
- ...changes
67
- } = buildFieldDefinitionColumns(payload);
87
+ return fieldDefinitionExecutor.update({ id: aggregateId, changes }, event.user, ctx.db, {
88
+ skipOptimisticLock: true,
89
+ });
90
+ },
91
+ };
92
+ }
68
93
 
69
- return fieldDefinitionExecutor.update({ id: aggregateId, changes }, event.user, ctx.db, {
70
- skipOptimisticLock: true,
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();
@@ -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
- // "send" isn't in the closed NavIconKey vocabulary (packages/types/src/
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
  });
@@ -67,6 +67,7 @@ export function createFeatureTogglesFeature(
67
67
  registered: r.queryHandler(registeredQuery),
68
68
  };
69
69
 
70
+ // kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
70
71
  r.screen({
71
72
  id: TOGGLE_ADMIN_SCREEN_ID,
72
73
  type: "custom",
@@ -38,6 +38,7 @@ export function ToggleAdminScreen(): ReactNode {
38
38
  setState({ kind: "ready", items: res.data.items });
39
39
  }, [dispatcher]);
40
40
 
41
+ // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
41
42
  useEffect(() => {
42
43
  void load();
43
44
  }, [load]);