@cosmicdrift/kumiko-bundled-features 0.211.0 → 0.213.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 (75) 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/__tests__/password-reset.integration.test.ts +31 -2
  12. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +175 -2
  13. package/src/auth-email-password/feature.ts +9 -4
  14. package/src/auth-email-password/handlers/invite-create.write.ts +14 -4
  15. package/src/auth-email-password/handlers/signup-request.write.ts +48 -34
  16. package/src/auth-email-password/handlers/token-request-handler.ts +20 -4
  17. package/src/auth-email-password/i18n.ts +2 -0
  18. package/src/auth-email-password/magic-link-mail.ts +11 -2
  19. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +15 -6
  20. package/src/auth-email-password/web/auth-form-primitives.tsx +2 -0
  21. package/src/auth-email-password/web/auth-gate.tsx +1 -0
  22. package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +1 -0
  23. package/src/auth-email-password/web/invite-accept-screen.tsx +3 -0
  24. package/src/auth-email-password/web/session.tsx +1 -0
  25. package/src/auth-email-password/web/signup-complete-screen.tsx +29 -14
  26. package/src/auth-email-password/web/verify-email-screen.tsx +1 -0
  27. package/src/auth-mfa/feature.ts +1 -0
  28. package/src/auth-mfa/web/mfa-disable-dialog.tsx +1 -0
  29. package/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +1 -0
  30. package/src/compliance-profiles/feature.ts +1 -5
  31. package/src/config/db/queries/__tests__/resolver.test.ts +64 -0
  32. package/src/config/db/queries/resolver.ts +5 -3
  33. package/src/crypto-shredding/changes.json +2 -1
  34. package/src/custom-fields/__tests__/feature.test.ts +42 -0
  35. package/src/custom-fields/__tests__/field-definition-role-config.integration.test.ts +139 -0
  36. package/src/custom-fields/constants.ts +6 -0
  37. package/src/custom-fields/feature.ts +56 -10
  38. package/src/custom-fields/handlers/define-tenant-field.write.ts +7 -1
  39. package/src/custom-fields/handlers/delete-tenant-field.write.ts +54 -33
  40. package/src/custom-fields/handlers/update-tenant-field.write.ts +58 -35
  41. package/src/delivery/feature.ts +1 -3
  42. package/src/feature-toggles/feature.ts +1 -0
  43. package/src/feature-toggles/web/toggle-admin-screen.tsx +1 -0
  44. package/src/folders/web/folder-manager.tsx +1 -0
  45. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +141 -0
  46. package/src/inbound-mail-foundation/watch-supervisor.ts +152 -11
  47. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +174 -0
  48. package/src/jobs/__tests__/jobs-stale-run-sweep.integration.test.ts +148 -0
  49. package/src/jobs/db/queries/stale-run-sweep.ts +51 -0
  50. package/src/jobs/feature.ts +25 -1
  51. package/src/jobs/handlers/detail.query.ts +13 -1
  52. package/src/jobs/handlers/stale-run-sweep.job.ts +28 -0
  53. package/src/jobs/job-run-logger.ts +68 -18
  54. package/src/jobs/web/job-run-detail-screen.tsx +1 -0
  55. package/src/page-render/web.ts +1 -0
  56. package/src/personal-access-tokens/feature.ts +1 -0
  57. package/src/secrets/__tests__/access-options.integration.test.ts +114 -0
  58. package/src/secrets/constants.ts +12 -0
  59. package/src/secrets/feature.ts +35 -9
  60. package/src/secrets/handlers/delete.write.ts +23 -16
  61. package/src/secrets/handlers/list.query.ts +37 -30
  62. package/src/secrets/handlers/set.write.ts +43 -36
  63. package/src/secrets/index.ts +5 -0
  64. package/src/seo/feature.ts +1 -0
  65. package/src/tags/feature.ts +1 -0
  66. package/src/tags/web/tag-chip.tsx +2 -0
  67. package/src/tags/web/tag-manager.tsx +2 -1
  68. package/src/tags/web/tag-picker.tsx +1 -0
  69. package/src/template-resolver/web/client-plugin.tsx +2 -0
  70. package/src/tier-engine/feature.ts +2 -0
  71. package/src/tier-engine/web/tier-admin-screen.tsx +5 -5
  72. package/src/user-data-rights/feature.ts +2 -0
  73. package/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
  74. package/src/user-profile/i18n.ts +1 -0
  75. package/src/user-profile/web/profile-screen.tsx +1 -1
@@ -1,19 +1,19 @@
1
1
  // @runtime client
2
2
  // SignupCompleteScreen — Magic-Link-Self-Signup, Step 2.
3
3
  //
4
- // Liest `?token=...` aus der URL, zeigt Form mit Password + Confirm.
5
- // Submit triggert /api/auth/signup-confirm — bei Erfolg setzt der
6
- // Server JWT + Cookies (Auto-Login!) und liefert tenantKey für den
7
- // Post-Signup-Redirect.
4
+ // Reads `?token=...` from the URL, shows a form with password + confirm.
5
+ // Submit posts to /api/auth/signup-confirm — on success the server sets
6
+ // JWT + cookies (auto-login!) and returns the tenantKey.
8
7
  //
9
- // Token-Quelle ist read-once via useUrlToken: liest `?token=...` beim
10
- // Mount und scrubbt den Param danach aus der URL (#774). Apps die einen
11
- // server-injected Token nutzen, reichen `token` als Prop durch.
8
+ // Token source is read-once via useUrlToken: reads `?token=...` on mount
9
+ // and scrubs the param from the URL afterwards (#774). Apps that inject a
10
+ // server-side token pass `token` as a prop instead.
12
11
  //
13
- // Nach success: redirect via window.location.assign zu loggedInHref.
14
- // Default-Pattern ist "/<tenantKey>/" — die App reicht ein Template
15
- // rein. Default-Template "/" wäre auch valide (App hat dann eigene
16
- // Routing-Logik die den eingeloggten User zur richtigen Page schickt).
12
+ // On success: shows a confirmation (account active, signed in) with a
13
+ // button to loggedInHref, instead of navigating away immediately — the
14
+ // server already logged the user in via cookies, but a silent redirect
15
+ // leaves no signal that activation worked. Default pattern is "/" — apps
16
+ // with multi-tenant routing pass `(data) => "/" + data.tenantKey + "/"`.
17
17
 
18
18
  import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
19
19
  import { type FormEvent, type ReactNode, useState } from "react";
@@ -50,6 +50,7 @@ export function SignupCompleteScreen({
50
50
  const [confirmPassword, setConfirmPassword] = useState("");
51
51
  const [submitting, setSubmitting] = useState(false);
52
52
  const [error, setError] = useState<string | null>(null);
53
+ const [continueHref, setContinueHref] = useState<string | null>(null);
53
54
 
54
55
  const doSubmit = async (): Promise<void> => {
55
56
  setError(null);
@@ -66,9 +67,10 @@ export function SignupCompleteScreen({
66
67
  const res = await confirmSignup(token, password);
67
68
  setSubmitting(false);
68
69
  if (res.ok) {
69
- // Auto-Login: Cookies sind via Set-Cookie schon im Browser. Wir
70
- // schicken den User direkt zur eingeloggten Page.
71
- window.location.assign(resolveLoggedInHref(loggedInHref, res.data.tenantKey));
70
+ // Cookies are already set (auto-login). Show a confirmation with an
71
+ // explicit continue button instead of navigating away silently —
72
+ // the user otherwise gets no signal that activation worked.
73
+ setContinueHref(resolveLoggedInHref(loggedInHref, res.data.tenantKey));
72
74
  return;
73
75
  }
74
76
  if (res.error.reason === "invalid_signup_token") {
@@ -104,6 +106,19 @@ export function SignupCompleteScreen({
104
106
  );
105
107
  }
106
108
 
109
+ if (continueHref !== null) {
110
+ return (
111
+ <AuthCard title={effectiveTitle}>
112
+ <div className="p-6 pt-0 flex flex-col gap-4">
113
+ <p className="text-sm text-muted-foreground">{t("auth.signupComplete.activated")}</p>
114
+ <Link href={continueHref} variant="button">
115
+ {t("auth.signupComplete.continue")}
116
+ </Link>
117
+ </div>
118
+ </AuthCard>
119
+ );
120
+ }
121
+
107
122
  return (
108
123
  <AuthCard title={effectiveTitle}>
109
124
  <div className="p-6 pt-0 flex flex-col gap-4">
@@ -36,6 +36,7 @@ export function VerifyEmailScreen({
36
36
  const [status, setStatus] = useState<Status>(token === "" ? "missing-token" : "verifying");
37
37
  const startedRef = useRef(false);
38
38
 
39
+ // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
39
40
  useEffect(() => {
40
41
  if (status !== "verifying") return;
41
42
  if (startedRef.current) return;
@@ -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
 
@@ -0,0 +1,64 @@
1
+ // #1163: config:query:readiness read config rows via asRawClient(db).unsafe()
2
+ // directly, bypassing the #1358 closed-connection retry that only covered
3
+ // bun-db/query.ts's own selectMany/countWhere. Routed through the exported
4
+ // unsafeReadRetrying helper instead — this test mirrors
5
+ // bun-db/__tests__/select-many-retry.test.ts's fake-client pattern to prove
6
+ // the retry now actually fires for this call site.
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { selectConfigRowsForKeys, selectConfigRowsForScope } from "../resolver";
10
+
11
+ function closedConnectionError(): Error {
12
+ return Object.assign(new Error("The connection was closed."), { name: "AbortError" });
13
+ }
14
+
15
+ type FakeClient = {
16
+ unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
17
+ begin: () => never;
18
+ calls: number;
19
+ };
20
+
21
+ function fakeClient(failures: Error[]): FakeClient {
22
+ const remaining = [...failures];
23
+ const client: FakeClient = {
24
+ calls: 0,
25
+ unsafe: async () => {
26
+ client.calls++;
27
+ const err = remaining.shift();
28
+ if (err) throw err;
29
+ return [{ id: "r1", key: "k", value: "v", tenantId: "t1", userId: null }];
30
+ },
31
+ // Top-level pool client — matches what dispatch-query.ts hands buildHandlerContext
32
+ // for a standalone query.execute() call (no tx passed, resolveDbSource falls back
33
+ // to the pool connection, which has .begin()).
34
+ begin: () => {
35
+ throw new Error("not used in test");
36
+ },
37
+ };
38
+ return client;
39
+ }
40
+
41
+ describe("config db/queries/resolver — closed-connection retry (#1163)", () => {
42
+ test("selectConfigRowsForScope retries once and returns rows", async () => {
43
+ const db = fakeClient([closedConnectionError()]);
44
+ const rows = await selectConfigRowsForScope(db as never, "system", "t1", "u1");
45
+ expect(rows).toHaveLength(1);
46
+ expect(rows[0]?.key).toBe("k");
47
+ expect(db.calls).toBe(2);
48
+ });
49
+
50
+ test("selectConfigRowsForKeys retries once and returns rows", async () => {
51
+ const db = fakeClient([closedConnectionError()]);
52
+ const rows = await selectConfigRowsForKeys(db as never, ["k"], "system", "t1", "u1");
53
+ expect(rows).toHaveLength(1);
54
+ expect(db.calls).toBe(2);
55
+ });
56
+
57
+ test("gives up after the single retry when the connection stays closed", async () => {
58
+ const db = fakeClient([closedConnectionError(), closedConnectionError()]);
59
+ await expect(selectConfigRowsForScope(db as never, "system", "t1", "u1")).rejects.toThrow(
60
+ "connection was closed",
61
+ );
62
+ expect(db.calls).toBe(2);
63
+ });
64
+ });
@@ -1,4 +1,4 @@
1
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
 
@@ -16,7 +16,8 @@ export async function selectConfigRowsForScope(
16
16
  tenantId: TenantId,
17
17
  userId: string,
18
18
  ): Promise<readonly ConfigRow[]> {
19
- return asRawClient(db).unsafe<ConfigRow>(
19
+ return unsafeReadRetrying<ConfigRow>(
20
+ db,
20
21
  `SELECT id, key, value, tenant_id AS "tenantId", user_id AS "userId"
21
22
  FROM read_config_values
22
23
  WHERE (tenant_id = $1 AND user_id IS NULL)
@@ -33,7 +34,8 @@ export async function selectConfigRowsForKeys(
33
34
  tenantId: TenantId,
34
35
  userId: string,
35
36
  ): Promise<readonly ConfigRow[]> {
36
- return asRawClient(db).unsafe<ConfigRow>(
37
+ return unsafeReadRetrying<ConfigRow>(
38
+ db,
37
39
  `SELECT id, key, value, tenant_id AS "tenantId", user_id AS "userId"
38
40
  FROM read_config_values
39
41
  WHERE key = ANY($1)
@@ -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;