@cosmicdrift/kumiko-bundled-features 0.35.0 → 0.38.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 (48) hide show
  1. package/package.json +5 -5
  2. package/src/auth-email-password/__tests__/auth-mailer.test.ts +138 -0
  3. package/src/auth-email-password/auth-mailer.ts +137 -0
  4. package/src/auth-email-password/email-templates.ts +7 -13
  5. package/src/auth-email-password/errors.ts +84 -0
  6. package/src/auth-email-password/handlers/change-password.write.ts +1 -10
  7. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -19
  8. package/src/auth-email-password/handlers/invite-accept.write.ts +15 -28
  9. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -14
  10. package/src/auth-email-password/handlers/login.write.ts +7 -51
  11. package/src/auth-email-password/handlers/reset-password.write.ts +3 -10
  12. package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -14
  13. package/src/auth-email-password/handlers/verify-email.write.ts +3 -10
  14. package/src/auth-email-password/index.ts +9 -0
  15. package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +24 -0
  16. package/src/auth-email-password/web/forgot-password-screen.tsx +1 -0
  17. package/src/auth-email-password/web/tenant-switcher.tsx +2 -1
  18. package/src/cap-counter/enforce-cap.ts +5 -0
  19. package/src/compliance-profiles/README.md +1 -1
  20. package/src/custom-fields/__tests__/feature.test.ts +1 -1
  21. package/src/custom-fields/__tests__/wire-for-entity.test.ts +4 -4
  22. package/src/custom-fields/db/queries/retention.ts +1 -0
  23. package/src/custom-fields/lib/parse-serialized-field.ts +11 -0
  24. package/src/custom-fields/run-retention.ts +4 -22
  25. package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +148 -0
  26. package/src/custom-fields/web/custom-fields-form-section.tsx +26 -12
  27. package/src/custom-fields/wire-for-entity.ts +4 -12
  28. package/src/custom-fields/wire-user-data-rights.ts +3 -22
  29. package/src/data-retention/__tests__/data-retention.integration.test.ts +2 -2
  30. package/src/file-foundation/feature.ts +13 -3
  31. package/src/file-foundation/index.ts +1 -0
  32. package/src/file-provider-inmemory/__tests__/feature.test.ts +4 -7
  33. package/src/file-provider-s3/__tests__/feature.test.ts +4 -6
  34. package/src/files/README.md +1 -1
  35. package/src/legal-pages/markdown.ts +1 -13
  36. package/src/renderer-simple/simple-renderer.ts +1 -8
  37. package/src/subscription-stripe/feature.ts +5 -2
  38. package/src/template-resolver/feature.ts +1 -2
  39. package/src/template-resolver/handlers/list.query.ts +7 -14
  40. package/src/template-resolver/handlers/toggle-status.write.ts +37 -0
  41. package/src/tenant/command-schemas.ts +1 -1
  42. package/src/tenant/feature.ts +1 -2
  43. package/src/tenant/handlers/toggle-enabled.write.ts +23 -0
  44. package/src/user-data-rights/README.md +8 -8
  45. package/src/template-resolver/handlers/archive.write.ts +0 -39
  46. package/src/template-resolver/handlers/publish.write.ts +0 -42
  47. package/src/tenant/handlers/disable.write.ts +0 -18
  48. package/src/tenant/handlers/enable.write.ts +0 -20
@@ -4,7 +4,6 @@ import {
4
4
  type SessionUser,
5
5
  type TenantId,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
- import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
8
7
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
9
8
  import { z } from "zod";
10
9
  import { USER_STATUS, UserQueries } from "../../user";
@@ -12,60 +11,17 @@ import { parseAuthUserRow } from "../auth-user-row";
12
11
  import {
13
12
  AUTH_LOCKOUT_DEFAULT_DURATION_MINUTES,
14
13
  AUTH_LOCKOUT_DEFAULT_MAX_FAILED_ATTEMPTS,
15
- AuthErrors,
16
14
  } from "../constants";
15
+ import {
16
+ accountLocked,
17
+ accountRestricted,
18
+ emailNotVerified,
19
+ invalidCredentials,
20
+ noMembership,
21
+ } from "../errors";
17
22
  import { clearLockoutState, getLockoutState, recordFailedAttempt } from "../lockout-store";
18
23
  import { verifyPassword } from "../password-hashing";
19
24
 
20
- function invalidCredentials() {
21
- return writeFailure(
22
- new UnprocessableError(AuthErrors.invalidCredentials, {
23
- i18nKey: "auth.errors.invalidCredentials",
24
- }),
25
- );
26
- }
27
-
28
- function noMembership() {
29
- return writeFailure(
30
- new UnprocessableError(AuthErrors.noMembership, {
31
- i18nKey: "auth.errors.noMembership",
32
- }),
33
- );
34
- }
35
-
36
- function emailNotVerified() {
37
- return writeFailure(
38
- new UnprocessableError(AuthErrors.emailNotVerified, {
39
- i18nKey: "auth.errors.emailNotVerified",
40
- }),
41
- );
42
- }
43
-
44
- function accountLocked(retryAfterSeconds: number) {
45
- return writeFailure(
46
- new UnprocessableError(AuthErrors.accountLocked, {
47
- i18nKey: "auth.errors.accountLocked",
48
- // Seconds until auto-unlock — UI renders a countdown, clients can
49
- // schedule a retry. Rounded up so the UI never shows 0 while the
50
- // lock is still active.
51
- details: { retryAfterSeconds },
52
- }),
53
- );
54
- }
55
-
56
- // S2.U6 — DSGVO Art. 18 Account-Freeze. Distinct error code (nicht zu
57
- // invalid_credentials collapsen) damit UI klar sagen kann "Account ist
58
- // pausiert, hier klicken zum Aufheben". User weiss schon dass sein Konto
59
- // restricted ist (er hat selbst die Restriction gesetzt), also kein
60
- // Enumeration-Leak.
61
- function accountRestricted() {
62
- return writeFailure(
63
- new UnprocessableError(AuthErrors.accountRestricted, {
64
- i18nKey: "auth.errors.accountRestricted",
65
- }),
66
- );
67
- }
68
-
69
25
  export type LoginHandlerOptions = {
70
26
  // When true, a valid (email + password) login fails with email_not_verified
71
27
  // if the user row's emailVerified flag is false. Enumeration-leak is
@@ -2,6 +2,7 @@ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import { z } from "zod";
4
4
  import { AuthErrors } from "../constants";
5
+ import { invalidResetToken } from "../errors";
5
6
  import { hashPassword } from "../password-hashing";
6
7
  import { verifyResetToken } from "../reset-token";
7
8
  import { runConfirmTokenFlow } from "./confirm-token-flow";
@@ -10,14 +11,6 @@ export type ResetPasswordOptions = {
10
11
  readonly hmacSecret: string;
11
12
  };
12
13
 
13
- function invalidToken() {
14
- return writeFailure(
15
- new UnprocessableError(AuthErrors.invalidResetToken, {
16
- i18nKey: "auth.errors.invalidResetToken",
17
- }),
18
- );
19
- }
20
-
21
14
  // Confirm step of the reset flow. Token-verify happens inline; the
22
15
  // post-verify pipeline (burn, load user, memberships, try-all-tenants,
23
16
  // burn-release-on-failure) lives in confirm-token-flow to stay in sync
@@ -45,12 +38,12 @@ export function createResetPasswordHandler(opts: ResetPasswordOptions) {
45
38
  // the same invalid_reset_token error — a probing caller can't
46
39
  // distinguish tampered from stale from random-string.
47
40
  const verify = verifyResetToken(event.payload.token, opts.hmacSecret);
48
- if (!verify.ok) return invalidToken();
41
+ if (!verify.ok) return invalidResetToken();
49
42
 
50
43
  return runConfirmTokenFlow(ctx, verify.userId, verify.expiresAtMs, {
51
44
  purpose: "reset",
52
45
  redisRequiredMessage: "password-reset requires ctx.redis to enforce token single-use",
53
- invalidToken,
46
+ invalidToken: invalidResetToken,
54
47
  buildChanges: async () => ({
55
48
  passwordHash: await hashPassword(event.payload.newPassword),
56
49
  }),
@@ -26,17 +26,13 @@ import {
26
26
  type SessionUser,
27
27
  type TenantId,
28
28
  } from "@cosmicdrift/kumiko-framework/engine";
29
- import {
30
- InternalError,
31
- UnprocessableError,
32
- writeFailure,
33
- } from "@cosmicdrift/kumiko-framework/errors";
29
+ import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
34
30
  import { generateUniqueName } from "@cosmicdrift/kumiko-framework/random";
35
31
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
36
32
  import { z } from "zod";
37
33
  // kumiko-lint-ignore cross-feature-import signup-confirm reads tenants.key for slug-uniqueness check (TOCTOU + DB-unique-index zusammen)
38
34
  import { tenantTable } from "../../tenant/schema/tenant";
39
- import { AuthErrors } from "../constants";
35
+ import { invalidSignupToken } from "../errors";
40
36
  // kumiko-lint-ignore cross-feature-import provisioning needs cross-feature seeding helpers
41
37
  import { INITIAL_SIGNUP_ROLES, provisionSignupAccount } from "../seeding";
42
38
  import {
@@ -63,14 +59,6 @@ export type SignupConfirmData = {
63
59
  readonly tenantKey: string;
64
60
  };
65
61
 
66
- function invalidSignupToken() {
67
- return writeFailure(
68
- new UnprocessableError(AuthErrors.invalidSignupToken, {
69
- i18nKey: "auth.errors.invalidSignupToken",
70
- }),
71
- );
72
- }
73
-
74
62
  export function createSignupConfirmHandler() {
75
63
  return defineWriteHandler<"signup-confirm", typeof SignupConfirmSchema, SignupConfirmData>({
76
64
  name: "signup-confirm",
@@ -2,6 +2,7 @@ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import { z } from "zod";
4
4
  import { AuthErrors } from "../constants";
5
+ import { invalidVerificationToken } from "../errors";
5
6
  import { verifyVerificationToken } from "../verification-token";
6
7
  import { runConfirmTokenFlow } from "./confirm-token-flow";
7
8
 
@@ -15,14 +16,6 @@ const VerifyEmailSchema = z.object({
15
16
 
16
17
  export type VerifyEmailData = { readonly kind: "verified" } | { readonly kind: "already-verified" };
17
18
 
18
- function invalidToken() {
19
- return writeFailure(
20
- new UnprocessableError(AuthErrors.invalidVerificationToken, {
21
- i18nKey: "auth.errors.invalidVerificationToken",
22
- }),
23
- );
24
- }
25
-
26
19
  // Sets user.emailVerified = true on a valid token. Idempotent via the
27
20
  // `alreadyDone` short-circuit — when the row already reads verified
28
21
  // (reached through another flow), we skip the write but keep the burn
@@ -44,12 +37,12 @@ export function createVerifyEmailHandler(opts: VerifyEmailOptions) {
44
37
  }
45
38
 
46
39
  const verify = verifyVerificationToken(event.payload.token, opts.hmacSecret);
47
- if (!verify.ok) return invalidToken();
40
+ if (!verify.ok) return invalidVerificationToken();
48
41
 
49
42
  return runConfirmTokenFlow<VerifyEmailData>(ctx, verify.userId, verify.expiresAtMs, {
50
43
  purpose: "verify",
51
44
  redisRequiredMessage: "email-verification requires ctx.redis to enforce token single-use",
52
- invalidToken,
45
+ invalidToken: invalidVerificationToken,
53
46
  buildChanges: async () => ({ emailVerified: true }),
54
47
  successData: { kind: "verified" },
55
48
  alreadyDone: {
@@ -1,3 +1,12 @@
1
+ // Factory für app-spezifische Auth-Mail-Configs. Baut passwordReset,
2
+ // emailVerification, signup und invite Setups gegen mailSender + Render-
3
+ // Funktionen — eliminiert Duplikate zwischen kumiko-studio, publicstatus
4
+ // und solon (jede App hatte identische send*Email-Wrapper kopiert).
5
+ export {
6
+ type AuthMailerConfig,
7
+ type CreateAuthMailerConfigArgs,
8
+ createAuthMailerConfig,
9
+ } from "./auth-mailer";
1
10
  export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers } from "./constants";
2
11
  // Default-HTML-Renderer für die Reset-Password + Verify-Email Mails.
3
12
  // Apps wiren die `sendResetEmail` / `sendVerificationEmail` callbacks
@@ -111,3 +111,27 @@ describe("TenantSwitcher", () => {
111
111
  expect(session.switchTenant).not.toHaveBeenCalled();
112
112
  });
113
113
  });
114
+
115
+ describe("TenantSwitcher — key-only-Fallback im geöffneten Dropdown", () => {
116
+ test("Membership ohne name rendert ihren key als Dropdown-Label", async () => {
117
+ const user = userEvent.setup();
118
+ const session = makeSessionApi({
119
+ activeTenantId: "00000000-0000-4000-8000-000000000001",
120
+ tenants: [
121
+ {
122
+ tenantId: "00000000-0000-4000-8000-000000000001",
123
+ roles: ["Admin"],
124
+ name: "Status",
125
+ key: "status",
126
+ },
127
+ { tenantId: "00000000-0000-4000-8000-000000000002", roles: ["Admin"], key: "demo" },
128
+ ],
129
+ });
130
+ renderWithProviders(<TenantSwitcher />, { session });
131
+ await user.click(screen.getByRole("button", { name: /Status/ }));
132
+ // Der key-only-Pfad (kein name) muss im Dropdown sichtbar werden —
133
+ // nicht das UUID-Präfix "00000000".
134
+ expect(screen.getByText("demo")).toBeTruthy();
135
+ expect(screen.queryByText("00000000")).toBeNull();
136
+ });
137
+ });
@@ -34,6 +34,7 @@ export function ForgotPasswordScreen({
34
34
  const [done, setDone] = useState(false);
35
35
  const [error, setError] = useState<string | null>(null);
36
36
 
37
+ // guard:dup-ok — gleiches Submit-Muster wie signup-screen, aber verschiedene API-Endpoints und State
37
38
  const doSubmit = async (): Promise<void> => {
38
39
  setSubmitting(true);
39
40
  setError(null);
@@ -63,7 +63,8 @@ export function TenantSwitcher({ tenantName }: TenantSwitcherProps): ReactNode {
63
63
  const nameOf = (tenantId: string): string => {
64
64
  if (tenantName !== undefined) return tenantName(tenantId);
65
65
  const membership = tenants.find((m) => m.tenantId === tenantId);
66
- return membership?.name ?? membership?.key ?? tenantId.slice(0, 8);
66
+ // || statt ??: ein leerer name/key-String darf nicht als Label durchrutschen.
67
+ return membership?.name || membership?.key || tenantId.slice(0, 8);
67
68
  };
68
69
 
69
70
  // Rendering-Gate: kein User → nix; nur ein Tenant → auch nix
@@ -413,6 +413,11 @@ export type StockCapResult =
413
413
  * (ein Delete gibt den Slot sofort frei), braucht keine Counter-Tabelle und
414
414
  * kein Increment/Decrement-Bookkeeping. Misst einen Bestand, keinen Fluss.
415
415
  *
416
+ * **TOCTOU-Caveat:** Zählen und Schreiben sind nicht atomar — zwei parallele
417
+ * Creates können beide `ok` sehen und das Limit um eins überschreiten (gleiche
418
+ * Race wie bei {@link enforceCap}). Exakt harte Slots brauchen zusätzliche
419
+ * Serialisierung im Create-Pfad (z.B. Unique-Index auf tenantId+slot).
420
+ *
416
421
  * Reine Funktion — wirft NICHT und mappt KEINEN HTTP-Status. Ein erreichtes
417
422
  * Stock-Limit heißt „Upgrade nötig", nicht „retry later" (429): der Caller
418
423
  * entscheidet die Reaktion, typisch ein app-eigener 422/`upgrade_required`
@@ -78,7 +78,7 @@ explizit als eigene Entity.
78
78
 
79
79
  ## Tests
80
80
 
81
- `__tests__/compliance-profiles.integration.ts` — 9 full-stack Tests via
81
+ `__tests__/compliance-profiles.integration.test.ts` — 9 full-stack Tests via
82
82
  `setupTestStack` + echte HTTP-Calls (Memory: `feedback_no_fake_dispatcher`):
83
83
  list-profiles, for-tenant ohne/mit Setting, set-profile als TenantAdmin /
84
84
  Member (403) / mit Override / mit invalidem JSON / mit Array statt Object /
@@ -12,7 +12,7 @@ describe("createCustomFieldsFeature shape", () => {
12
12
  test("registers field-definition entity + 6 write-handlers + 1 query-handler", () => {
13
13
  const feature = createCustomFieldsFeature();
14
14
 
15
- expect(Object.keys(feature.entities)).toContain("field-definition");
15
+ expect(Object.keys(feature.entities ?? {})).toContain("field-definition");
16
16
 
17
17
  const writeHandlerNames = Object.keys(feature.writeHandlers);
18
18
  expect(writeHandlerNames).toEqual(
@@ -40,7 +40,7 @@ describe("wireCustomFieldsFor", () => {
40
40
  );
41
41
 
42
42
  // 3. postQuery entity-hook on "property"
43
- expect(feature.entityHooks.postQuery["property"]).toHaveLength(1);
43
+ expect(feature.entityHooks?.postQuery?.["property"]).toHaveLength(1);
44
44
 
45
45
  // 4. search-payload-extension on "property"
46
46
  expect(feature.searchPayloadExtensions!["property"]).toHaveLength(1);
@@ -52,7 +52,7 @@ describe("wireCustomFieldsFor", () => {
52
52
  wireCustomFieldsFor(r, "property", propertyTable);
53
53
  });
54
54
 
55
- const hook = feature.entityHooks.postQuery["property"]?.[0]?.fn;
55
+ const hook = feature.entityHooks?.postQuery?.["property"]?.[0]?.fn;
56
56
  expect(hook).toBeDefined();
57
57
  const result = await hook?.(
58
58
  {
@@ -82,7 +82,7 @@ describe("wireCustomFieldsFor", () => {
82
82
  wireCustomFieldsFor(r, "property", propertyTable);
83
83
  });
84
84
 
85
- const hook = feature.entityHooks.postQuery["property"]?.[0]?.fn;
85
+ const hook = feature.entityHooks?.postQuery?.["property"]?.[0]?.fn;
86
86
  const result = await hook?.(
87
87
  {
88
88
  entityName: "property",
@@ -111,7 +111,7 @@ describe("wireCustomFieldsFor", () => {
111
111
  wireCustomFieldsFor(r, "property", propertyTable);
112
112
  });
113
113
 
114
- const hook = feature.entityHooks.postQuery["property"]?.[0]?.fn;
114
+ const hook = feature.entityHooks?.postQuery?.["property"]?.[0]?.fn;
115
115
  const result = await hook?.(
116
116
  {
117
117
  entityName: "property",
@@ -1,6 +1,7 @@
1
1
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
3
 
4
+ // guard:dup-ok — andere SQL als selectFieldDefinitionsForEntity; gleiche Bezeichner, verschiedene Queries
4
5
  export async function selectFieldDefinitionsWithSerialized(
5
6
  db: DbRunner,
6
7
  entityName: string,
@@ -43,3 +43,14 @@ export function parseSerializedField(raw: unknown): SerializedFieldShape | null
43
43
  const parsed = typeof raw === "string" ? parseJsonSafe<unknown>(raw, null) : raw;
44
44
  return isShape(parsed) ? parsed : null;
45
45
  }
46
+
47
+ export interface FieldDefinitionRow {
48
+ readonly field_key: string;
49
+ readonly serialized_field: unknown;
50
+ }
51
+
52
+ export function isFieldDefinitionRow(value: unknown): value is FieldDefinitionRow {
53
+ if (!value || typeof value !== "object") return false;
54
+ if (!("field_key" in value)) return false;
55
+ return typeof value.field_key === "string";
56
+ }
@@ -16,22 +16,14 @@
16
16
  // jsonb shape, which would be a breaking schema change.
17
17
 
18
18
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
19
+ import { extractTableName } from "@cosmicdrift/kumiko-framework/db";
19
20
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
20
21
  import {
21
22
  applyRetentionRemovals,
22
23
  selectFieldDefinitionsWithSerialized,
23
24
  selectHostRowsWithCustomFields,
24
25
  } from "./db/queries/retention";
25
- import { parseSerializedField } from "./lib/parse-serialized-field";
26
-
27
- const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
28
- function getTableName(table: unknown): string {
29
- if (typeof table === "object" && table !== null) {
30
- const sym = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
31
- if (typeof sym === "string") return sym;
32
- }
33
- throw new Error("custom-fields/run-retention: table missing kumiko:schema:Name symbol");
34
- }
26
+ import { isFieldDefinitionRow, parseSerializedField } from "./lib/parse-serialized-field";
35
27
 
36
28
  type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
37
29
 
@@ -87,7 +79,7 @@ export async function runCustomFieldsRetention(
87
79
  return { rowsScanned: 0, rowsUpdated: 0, removalsByFieldKey: {} };
88
80
  }
89
81
 
90
- const tableName = getTableName(opts.entityTable);
82
+ const tableName = extractTableName(opts.entityTable, "custom-fields/run-retention");
91
83
  const rows = await selectHostRowsWithCustomFields(opts.db, tableName, opts.tenantId);
92
84
 
93
85
  const removalsByFieldKey: Record<string, number> = {};
@@ -159,17 +151,6 @@ function asHostRow(value: unknown): HostRow | null {
159
151
  };
160
152
  }
161
153
 
162
- interface FieldDefinitionRow {
163
- readonly field_key: string;
164
- readonly serialized_field: unknown;
165
- }
166
-
167
- function isFieldDefinitionRow(value: unknown): value is FieldDefinitionRow {
168
- if (!value || typeof value !== "object") return false;
169
- if (!("field_key" in value)) return false;
170
- return typeof value.field_key === "string";
171
- }
172
-
173
154
  async function loadRetentionPolicies(
174
155
  db: DbRunner,
175
156
  tenantId: string,
@@ -192,6 +173,7 @@ interface InstantLike {
192
173
  readonly epochMilliseconds: number;
193
174
  }
194
175
 
176
+ // guard:dup-ok — false positive: gleiche TypeGuard-Struktur wie isFieldDefinitionRow, völlig andere Semantik
195
177
  function isInstantLike(value: unknown): value is InstantLike {
196
178
  if (!value || typeof value !== "object") return false;
197
179
  if (!("epochMilliseconds" in value)) return false;
@@ -234,3 +234,151 @@ describe("CustomFieldsFormSection", () => {
234
234
  expect(saveBtn.textContent).toBe("Save custom fields");
235
235
  });
236
236
  });
237
+
238
+ describe("CustomFieldsFormSection — clear-Pfad", () => {
239
+ test("Leeren eines gespeicherten Werts dispatched clear-custom-field (nicht skip)", async () => {
240
+ mockedQueryRows = [
241
+ {
242
+ id: "f1",
243
+ entityName: "component",
244
+ fieldKey: "vendor",
245
+ type: "text",
246
+ required: false,
247
+ displayOrder: 1,
248
+ },
249
+ ];
250
+ dispatchSpy.mockClear();
251
+
252
+ render(
253
+ <Wrapper>
254
+ <CustomFieldsFormSection
255
+ entityName="component"
256
+ entityId="row-42"
257
+ initialValues={{ vendor: "Hetzner" }}
258
+ />
259
+ </Wrapper>,
260
+ );
261
+
262
+ const vendorInput = document.getElementById("custom-field-vendor") as HTMLInputElement;
263
+ expect(vendorInput.value).toBe("Hetzner");
264
+
265
+ fireEvent.change(vendorInput, { target: { value: "" } });
266
+ fireEvent.click(screen.getByTestId("custom-fields-form-save"));
267
+ await Promise.resolve();
268
+ await Promise.resolve();
269
+
270
+ expect(dispatchSpy).toHaveBeenCalledTimes(1);
271
+ expect(dispatchSpy).toHaveBeenCalledWith("custom-fields:write:clear-custom-field", {
272
+ entityName: "component",
273
+ entityId: "row-42",
274
+ fieldKey: "vendor",
275
+ });
276
+ });
277
+
278
+ test("unveränderter Bestandswert wird beim Save NICHT erneut geschrieben", async () => {
279
+ mockedQueryRows = [
280
+ {
281
+ id: "f1",
282
+ entityName: "component",
283
+ fieldKey: "vendor",
284
+ type: "text",
285
+ required: false,
286
+ displayOrder: 1,
287
+ },
288
+ ];
289
+ dispatchSpy.mockClear();
290
+
291
+ render(
292
+ <Wrapper>
293
+ <CustomFieldsFormSection
294
+ entityName="component"
295
+ entityId="row-42"
296
+ initialValues={{ vendor: "Hetzner" }}
297
+ />
298
+ </Wrapper>,
299
+ );
300
+
301
+ const vendorInput = document.getElementById("custom-field-vendor") as HTMLInputElement;
302
+ // Tippen + zurück auf den Bestandswert → nicht dirty, kein Write.
303
+ fireEvent.change(vendorInput, { target: { value: "Hetzner2" } });
304
+ fireEvent.change(vendorInput, { target: { value: "Hetzner" } });
305
+ fireEvent.click(screen.getByTestId("custom-fields-form-save"));
306
+ await Promise.resolve();
307
+ await Promise.resolve();
308
+
309
+ expect(dispatchSpy).not.toHaveBeenCalled();
310
+ });
311
+ });
312
+
313
+ describe("CustomFieldsFormSection — boolean/date-Pfade", () => {
314
+ test("boolean: Bestand wird als true/false-String angezeigt, Save coerced zu boolean", async () => {
315
+ mockedQueryRows = [
316
+ {
317
+ id: "f1",
318
+ entityName: "component",
319
+ fieldKey: "active",
320
+ type: "boolean",
321
+ required: false,
322
+ displayOrder: 1,
323
+ },
324
+ ];
325
+ dispatchSpy.mockClear();
326
+
327
+ render(
328
+ <Wrapper>
329
+ <CustomFieldsFormSection
330
+ entityName="component"
331
+ entityId="row-42"
332
+ initialValues={{ active: true }}
333
+ />
334
+ </Wrapper>,
335
+ );
336
+
337
+ // boolean rendert als Checkbox — Bestand steckt in checked, nicht value.
338
+ const input = document.getElementById("custom-field-active") as HTMLInputElement;
339
+ expect(input.checked).toBe(true);
340
+
341
+ fireEvent.click(input);
342
+ fireEvent.click(screen.getByTestId("custom-fields-form-save"));
343
+ await Promise.resolve();
344
+ await Promise.resolve();
345
+
346
+ expect(dispatchSpy).toHaveBeenCalledWith("custom-fields:write:set-custom-field", {
347
+ entityName: "component",
348
+ entityId: "row-42",
349
+ fieldKey: "active",
350
+ value: false,
351
+ });
352
+ });
353
+
354
+ test("date: Bestand erreicht den DateInput-Trigger (lokalisierte Anzeige)", () => {
355
+ mockedQueryRows = [
356
+ {
357
+ id: "f1",
358
+ entityName: "component",
359
+ fieldKey: "launchedAt",
360
+ type: "date",
361
+ required: false,
362
+ displayOrder: 1,
363
+ },
364
+ ];
365
+ dispatchSpy.mockClear();
366
+
367
+ render(
368
+ <Wrapper>
369
+ <CustomFieldsFormSection
370
+ entityName="component"
371
+ entityId="row-42"
372
+ initialValues={{ launchedAt: "2026-01-15" }}
373
+ />
374
+ </Wrapper>,
375
+ );
376
+
377
+ // DateInput ist ein Kalender-Trigger-Button (kein <input value>) mit
378
+ // lokalisierter Anzeige — der Kalender-Flow selbst ist jsdom-untauglich
379
+ // (Popover), hier zählt: der Bestand kommt im Trigger an, nicht "—".
380
+ const trigger = document.getElementById("custom-field-launchedAt") as HTMLButtonElement;
381
+ expect(trigger.textContent).toContain("2026");
382
+ expect(trigger.textContent).not.toContain("—");
383
+ });
384
+ });
@@ -92,20 +92,35 @@ export function CustomFieldsFormSection({
92
92
  );
93
93
  }
94
94
 
95
+ // Dirty heißt: weicht vom GESPEICHERTEN Wert ab — nicht von "". Sonst ist
96
+ // das Leeren eines gespeicherten Werts unsichtbar (Button disabled) und
97
+ // handleSave würde es überspringen statt zu clearen.
98
+ const initialDisplay = (field: (typeof matchingFields)[number]): string =>
99
+ displayValue(field.type, initialValues?.[field.fieldKey]);
100
+ const changedFields = matchingFields.filter((field) => {
101
+ const raw = pending[field.fieldKey];
102
+ return raw !== undefined && raw !== initialDisplay(field);
103
+ });
104
+
95
105
  const handleSave = async (): Promise<void> => {
96
106
  setSaving(true);
97
107
  setErrorKey(null);
98
108
  try {
99
- for (const field of matchingFields) {
100
- const raw = pending[field.fieldKey];
101
- if (raw === undefined || raw === "") continue;
102
- const value = coerceValue(field.type, raw);
103
- const result = await dispatcher.write(CustomFieldsHandlers.setCustomField, {
104
- entityName,
105
- entityId,
106
- fieldKey: field.fieldKey,
107
- value,
108
- });
109
+ for (const field of changedFields) {
110
+ const raw = pending[field.fieldKey] ?? "";
111
+ const result =
112
+ raw === ""
113
+ ? await dispatcher.write(CustomFieldsHandlers.clearCustomField, {
114
+ entityName,
115
+ entityId,
116
+ fieldKey: field.fieldKey,
117
+ })
118
+ : await dispatcher.write(CustomFieldsHandlers.setCustomField, {
119
+ entityName,
120
+ entityId,
121
+ fieldKey: field.fieldKey,
122
+ value: coerceValue(field.type, raw),
123
+ });
109
124
  if (!result.isSuccess) {
110
125
  setErrorKey(result.error?.i18nKey ?? "custom-fields.errors.saveFailed");
111
126
  return;
@@ -117,7 +132,7 @@ export function CustomFieldsFormSection({
117
132
  }
118
133
  };
119
134
 
120
- const dirty = Object.values(pending).some((v) => v !== "");
135
+ const dirty = changedFields.length > 0;
121
136
 
122
137
  return (
123
138
  <div data-testid="custom-fields-form-section">
@@ -204,6 +219,5 @@ function coerceValue(type: string, raw: string): unknown {
204
219
  function displayValue(type: string, value: unknown): string {
205
220
  if (value === undefined || value === null) return "";
206
221
  if (type === "boolean") return value === true ? "true" : "false";
207
- if (type === "number") return typeof value === "number" ? String(value) : String(value);
208
222
  return String(value);
209
223
  }
@@ -1,3 +1,4 @@
1
+ import { extractTableName } from "@cosmicdrift/kumiko-framework/db";
1
2
  import {
2
3
  createJsonbField,
3
4
  type FeatureRegistrar,
@@ -13,15 +14,6 @@ import {
13
14
  setCustomFieldValue,
14
15
  } from "./db/queries/projection";
15
16
 
16
- const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
17
- function getTableName(table: unknown): string {
18
- if (typeof table === "object" && table !== null) {
19
- const sym = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
20
- if (typeof sym === "string") return sym;
21
- }
22
- throw new Error("wire-for-entity: table missing kumiko:schema:Name symbol");
23
- }
24
-
25
17
  import type { CustomFieldClearedPayload, CustomFieldSetPayload } from "./events";
26
18
  import { customFieldsFeature } from "./feature";
27
19
 
@@ -107,7 +99,7 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
107
99
  // jsonb_set: setze key auf value. Wenn key noch nicht existiert →
108
100
  // wird angelegt (create_missing=true ist default). value muss als
109
101
  // jsonb-literal kommen.
110
- const tableName = getTableName(entityTable);
102
+ const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
111
103
  await setCustomFieldValue(
112
104
  tx,
113
105
  tableName,
@@ -124,7 +116,7 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
124
116
  const payload = event.payload as CustomFieldClearedPayload; // @cast-boundary engine-payload
125
117
 
126
118
  // jsonb minus operator (`-`) entfernt key aus jsonb-object.
127
- const tableName = getTableName(entityTable);
119
+ const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
128
120
  await clearCustomFieldKey(
129
121
  tx,
130
122
  tableName,
@@ -147,7 +139,7 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
147
139
  // ihre Rows.
148
140
  if (payload.entityName !== entityName) return;
149
141
 
150
- const tableName = getTableName(entityTable);
142
+ const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
151
143
  // Scope cleanup to the deleted definition's owning tenant. System-scope
152
144
  // definitions apply to every tenant → cascade across all rows; tenant-
153
145
  // scope deletions must only touch that tenant's rows, else deleting one