@cosmicdrift/kumiko-framework 0.104.0 → 0.105.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.104.0",
3
+ "version": "0.105.1",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -181,7 +181,7 @@
181
181
  "zod": "^4.4.3"
182
182
  },
183
183
  "devDependencies": {
184
- "@cosmicdrift/kumiko-dispatcher-live": "0.104.0",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.105.1",
185
185
  "bun-types": "^1.3.13",
186
186
  "pino-pretty": "^13.1.3"
187
187
  },
@@ -0,0 +1,45 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createEntity, createTextField } from "../../engine";
3
+ import { createEncryptionProvider } from "../encryption";
4
+ import {
5
+ collectEncryptedFieldNames,
6
+ decryptEntityFieldValues,
7
+ encryptEntityFieldValues,
8
+ } from "../entity-field-encryption";
9
+
10
+ const TEST_KEY = Buffer.from("a]bJm#kP9xQ2@wN!vL$hR5yT8eU0iO3f").toString("base64");
11
+
12
+ describe("entity-field-encryption", () => {
13
+ const entity = createEntity({
14
+ table: "read_enc_test",
15
+ fields: {
16
+ email: createTextField({ required: true }),
17
+ secretNote: createTextField({ encrypted: true }),
18
+ },
19
+ });
20
+ const encryptedFields = collectEncryptedFieldNames(entity);
21
+ const encryption = createEncryptionProvider(TEST_KEY);
22
+
23
+ test("collectEncryptedFieldNames finds encrypted text fields only", () => {
24
+ expect([...encryptedFields]).toEqual(["secretNote"]);
25
+ });
26
+
27
+ test("encrypt on write / decrypt on read round-trip", () => {
28
+ const plain = { email: "a@b.de", secretNote: "top secret" };
29
+ const stored = encryptEntityFieldValues(plain, encryptedFields, encryption);
30
+ expect(stored["email"]).toBe(plain.email);
31
+ expect(stored["secretNote"]).not.toBe("top secret");
32
+
33
+ const read = decryptEntityFieldValues(stored, encryptedFields, encryption);
34
+ expect(read).toEqual(plain);
35
+ });
36
+
37
+ test("onlyKeys limits encryption to changed fields", () => {
38
+ const row = { email: "a@b.de", secretNote: "note" };
39
+ const stored = encryptEntityFieldValues(row, encryptedFields, encryption, {
40
+ onlyKeys: ["secretNote"],
41
+ });
42
+ expect(stored["email"]).toBe("a@b.de");
43
+ expect(stored["secretNote"]).not.toBe("note");
44
+ });
45
+ });
@@ -10,6 +10,8 @@ import {
10
10
  testTenantId,
11
11
  unsafeCreateEntityTable,
12
12
  } from "../../stack";
13
+ import { createEncryptionProvider } from "../encryption";
14
+ import { resetEntityFieldEncryptionCacheForTests } from "../entity-field-encryption";
13
15
  import { createEventStoreExecutor } from "../event-store-executor";
14
16
  import { buildEntityTable } from "../table-builder";
15
17
  import { createTenantDb, type TenantDb } from "../tenant-db";
@@ -301,6 +303,77 @@ describe("event-store-executor — detail liefert die Stream-Version", () => {
301
303
  });
302
304
  });
303
305
 
306
+ const ENCRYPTION_TEST_KEY = Buffer.from("a]bJm#kP9xQ2@wN!vL$hR5yT8eU0iO3f").toString("base64");
307
+ const encryptedEntity = createEntity({
308
+ table: "read_es_exec_encrypted",
309
+ fields: {
310
+ email: createTextField({ required: true }),
311
+ secretNote: createTextField({ encrypted: true }),
312
+ },
313
+ });
314
+ const encryptedTable = buildEntityTable("esExecEncrypted", encryptedEntity);
315
+
316
+ describe("event-store-executor — encrypted fields", () => {
317
+ const encryption = createEncryptionProvider(ENCRYPTION_TEST_KEY);
318
+ const crud = createEventStoreExecutor(encryptedTable, encryptedEntity, {
319
+ entityName: "esExecEncrypted",
320
+ encryption,
321
+ });
322
+
323
+ beforeAll(async () => {
324
+ process.env["ENCRYPTION_KEY"] = ENCRYPTION_TEST_KEY;
325
+ resetEntityFieldEncryptionCacheForTests();
326
+ await unsafeCreateEntityTable(testDb.db, encryptedEntity, "esExecEncrypted");
327
+ });
328
+
329
+ beforeEach(async () => {
330
+ await asRawClient(testDb.db).unsafe(
331
+ `TRUNCATE kumiko_events, read_es_exec_encrypted RESTART IDENTITY CASCADE`,
332
+ );
333
+ });
334
+
335
+ test("stores ciphertext in DB and returns plaintext on read", async () => {
336
+ const plaintext = "super-secret-note";
337
+ const result = await crud.create(
338
+ { email: "enc@test.de", secretNote: plaintext },
339
+ adminUser,
340
+ tdb,
341
+ );
342
+ if (!result.isSuccess) throw new Error("create failed");
343
+ expect(result.data.data["secretNote"]).toBe(plaintext);
344
+
345
+ const rawRows = (await asRawClient(testDb.db).unsafe(
346
+ `SELECT secret_note FROM read_es_exec_encrypted WHERE email = 'enc@test.de' LIMIT 1`,
347
+ )) as Array<{ secret_note: string }>;
348
+ expect(rawRows[0]?.secret_note).toBeDefined();
349
+ expect(rawRows[0]!.secret_note).not.toBe(plaintext);
350
+
351
+ const detail = await crud.detail({ id: result.data.id }, adminUser, tdb);
352
+ expect(detail?.["secretNote"]).toBe(plaintext);
353
+ });
354
+
355
+ test("update encrypts changed encrypted field", async () => {
356
+ const created = await crud.create(
357
+ { email: "upd-enc@test.de", secretNote: "old-note" },
358
+ adminUser,
359
+ tdb,
360
+ );
361
+ if (!created.isSuccess) throw new Error("create failed");
362
+
363
+ const updated = await crud.update(
364
+ {
365
+ id: created.data.id,
366
+ version: 1,
367
+ changes: { secretNote: "new-note" },
368
+ },
369
+ adminUser,
370
+ tdb,
371
+ );
372
+ if (!updated.isSuccess) throw new Error("update failed");
373
+ expect(updated.data.data["secretNote"]).toBe("new-note");
374
+ });
375
+ });
376
+
304
377
  describe("event-store-executor — entity cache read-through", () => {
305
378
  // In-memory stand-in for EntityCache — no Redis needed.
306
379
  const store = new Map<string, Record<string, unknown>>();
@@ -0,0 +1,81 @@
1
+ import type { EntityDefinition } from "../engine/types";
2
+ import type { EncryptionProvider } from "./encryption";
3
+ import { createEncryptionProvider } from "./encryption";
4
+
5
+ export function collectEncryptedFieldNames(entity: EntityDefinition): ReadonlySet<string> {
6
+ const names = new Set<string>();
7
+ for (const [name, field] of Object.entries(entity.fields)) {
8
+ if ((field.type === "text" || field.type === "longText") && field.encrypted === true) {
9
+ names.add(name);
10
+ }
11
+ }
12
+ return names;
13
+ }
14
+
15
+ function encryptFieldValue(
16
+ fieldName: string,
17
+ value: unknown,
18
+ encryption: EncryptionProvider,
19
+ ): string {
20
+ if (value === null || value === undefined) {
21
+ throw new Error(`encrypted field "${fieldName}" cannot be null or undefined`);
22
+ }
23
+ if (typeof value !== "string") {
24
+ throw new Error(`encrypted field "${fieldName}" must be a string, got ${typeof value}`);
25
+ }
26
+ return encryption.encrypt(value);
27
+ }
28
+
29
+ export function encryptEntityFieldValues(
30
+ row: Record<string, unknown>,
31
+ encryptedFields: ReadonlySet<string>,
32
+ encryption: EncryptionProvider,
33
+ opts?: { onlyKeys?: Iterable<string> },
34
+ ): Record<string, unknown> {
35
+ if (encryptedFields.size === 0) return row;
36
+ const only = opts?.onlyKeys ? new Set(opts.onlyKeys) : null;
37
+ const out = { ...row };
38
+ for (const name of encryptedFields) {
39
+ if (only && !only.has(name)) continue;
40
+ if (!(name in out)) continue;
41
+ const value = out[name];
42
+ if (value === null || value === undefined) continue;
43
+ out[name] = encryptFieldValue(name, value, encryption);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ export function decryptEntityFieldValues(
49
+ row: Record<string, unknown>,
50
+ encryptedFields: ReadonlySet<string>,
51
+ encryption: EncryptionProvider,
52
+ ): Record<string, unknown> {
53
+ if (encryptedFields.size === 0) return row;
54
+ const out = { ...row };
55
+ for (const name of encryptedFields) {
56
+ const value = out[name];
57
+ if (value === null || value === undefined) continue;
58
+ if (typeof value !== "string") continue;
59
+ out[name] = encryption.decrypt(value);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ let cachedProvider: EncryptionProvider | undefined;
65
+
66
+ export function resolveEntityFieldEncryption(): EncryptionProvider {
67
+ if (cachedProvider) return cachedProvider;
68
+ const key = process.env["ENCRYPTION_KEY"];
69
+ if (!key) {
70
+ throw new Error(
71
+ "ENCRYPTION_KEY environment variable is required (encrypted entity fields in use)",
72
+ );
73
+ }
74
+ cachedProvider = createEncryptionProvider(key);
75
+ return cachedProvider;
76
+ }
77
+
78
+ /** @internal test-only */
79
+ export function resetEntityFieldEncryptionCacheForTests(): void {
80
+ cachedProvider = undefined;
81
+ }
@@ -45,6 +45,13 @@ import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
45
45
  import type { DbRow } from "./connection";
46
46
  import { decodeCursor, encodeCursor } from "./cursor";
47
47
  import type { TableColumns } from "./dialect";
48
+ import type { EncryptionProvider } from "./encryption";
49
+ import {
50
+ collectEncryptedFieldNames,
51
+ decryptEntityFieldValues,
52
+ encryptEntityFieldValues,
53
+ resolveEntityFieldEncryption,
54
+ } from "./entity-field-encryption";
48
55
  import type { CursorResult } from "./index";
49
56
  import { constraintOf, isUniqueViolation } from "./pg-error";
50
57
  import { toSnakeCase } from "./table-builder";
@@ -112,6 +119,8 @@ export type EventStoreExecutorOptions = {
112
119
  searchAdapter?: SearchAdapter;
113
120
  entityName: string; // required — the aggregateType marker on every event
114
121
  entityCache?: EntityCache;
122
+ /** Override ENCRYPTION_KEY for entity fields marked `encrypted: true`. */
123
+ encryption?: EncryptionProvider;
115
124
  };
116
125
 
117
126
  // F8 helper: PG-23505 (unique-violation) catched aus applyEntityEvent
@@ -285,6 +294,29 @@ export function createEventStoreExecutor(
285
294
  }
286
295
  }
287
296
 
297
+ const encryptedFields = collectEncryptedFieldNames(entity);
298
+ const hasEncryptedFields = encryptedFields.size > 0;
299
+
300
+ function encryptionProvider(): EncryptionProvider {
301
+ if (options.encryption) return options.encryption;
302
+ return resolveEntityFieldEncryption();
303
+ }
304
+
305
+ function encryptForStorage(
306
+ row: Record<string, unknown>,
307
+ onlyKeys?: Iterable<string>,
308
+ ): Record<string, unknown> {
309
+ if (!hasEncryptedFields) return row;
310
+ return encryptEntityFieldValues(row, encryptedFields, encryptionProvider(), {
311
+ ...(onlyKeys !== undefined && { onlyKeys }),
312
+ });
313
+ }
314
+
315
+ function decryptForRead(row: Record<string, unknown>): Record<string, unknown> {
316
+ if (!hasEncryptedFields) return row;
317
+ return decryptEntityFieldValues(row, encryptedFields, encryptionProvider());
318
+ }
319
+
288
320
  function applyDefaults(payload: Record<string, unknown>): Record<string, unknown> {
289
321
  if (Object.keys(fieldDefaults).length === 0) return payload;
290
322
  const result: Record<string, unknown> = { ...payload };
@@ -314,7 +346,7 @@ export function createEventStoreExecutor(
314
346
  async function loadById(id: EntityId, db: TenantDb): Promise<Record<string, unknown> | null> {
315
347
  const row = await db.fetchOne(table, idFilter(id));
316
348
  if (!row) return null;
317
- return rehydrateCompoundTypes(row as DbRow, entity);
349
+ return decryptForRead(rehydrateCompoundTypes(row as DbRow, entity));
318
350
  }
319
351
 
320
352
  // Archive guard for the CRUD write paths. Archived streams are read-only —
@@ -423,7 +455,7 @@ export function createEventStoreExecutor(
423
455
  // Alle Compound-Types (locatedTimestamp, money, ...) gehen durch
424
456
  // dieselbe Pipeline. Caller schickt combined API-Form, Framework
425
457
  // speichert flat DB-Form. Siehe db/compound-types.ts.
426
- const flatData = flattenCompoundTypes(data, entity);
458
+ const flatData = encryptForStorage(flattenCompoundTypes(data, entity));
427
459
 
428
460
  // 1. Append event (same TX as the projection write — both must succeed
429
461
  // or both roll back; the dispatcher wraps both in one transaction).
@@ -499,7 +531,7 @@ export function createEventStoreExecutor(
499
531
  const row = result.row;
500
532
  // Read-Side Auto-Convert: DB-Form → API-combined-Form für alle
501
533
  // Compound-Types in einem Pass.
502
- const projection = rehydrateCompoundTypes(row as DbRow, entity) as DbRow;
534
+ const projection = decryptForRead(rehydrateCompoundTypes(row as DbRow, entity) as DbRow);
503
535
 
504
536
  if (entityCache && entityName) {
505
537
  await entityCache.del(user.tenantId, entityName, aggregateId);
@@ -602,7 +634,10 @@ export function createEventStoreExecutor(
602
634
 
603
635
  try {
604
636
  // Compound-Types Auto-Convert (alle in einem Pass).
605
- const flatChanges = flattenCompoundTypes(payload.changes, entity);
637
+ const flatChanges = encryptForStorage(
638
+ flattenCompoundTypes(payload.changes, entity),
639
+ Object.keys(payload.changes),
640
+ );
606
641
 
607
642
  // The event payload carries BOTH `changes` (what the user asked for) AND
608
643
  // `previous` (the pre-update row). Cross-aggregate projections need the
@@ -647,7 +682,7 @@ export function createEventStoreExecutor(
647
682
  return writeFailure(new InternalError({ message: "projection update returned no row" }));
648
683
  }
649
684
  const row = result.row;
650
- const data = rehydrateCompoundTypes(row as DbRow, entity) as DbRow;
685
+ const data = decryptForRead(rehydrateCompoundTypes(row as DbRow, entity) as DbRow);
651
686
 
652
687
  if (entityCache && entityName) {
653
688
  await entityCache.del(user.tenantId, entityName, payload.id);
@@ -967,7 +1002,9 @@ export function createEventStoreExecutor(
967
1002
  const rawRows = await executeRawQuery<Record<string, unknown>>(db.raw, listSql, params);
968
1003
  // Read-Side rehydrate pro Row + snake→camel coercion für driver-agnostic Feldnamen
969
1004
  const tableInfo = extractTableInfo(table);
970
- const rows = rawRows.map((r) => coerceRow(rehydrateCompoundTypes(r, entity), tableInfo));
1005
+ const rows = rawRows.map((r) =>
1006
+ coerceRow(decryptForRead(rehydrateCompoundTypes(r, entity)), tableInfo),
1007
+ );
971
1008
 
972
1009
  // list rows carry the READ-ROW version (display-only), never an optimistic-lock
973
1010
  // base — edit flows reload via detail(), which reconciles the stream version.
@@ -1043,7 +1080,7 @@ export function createEventStoreExecutor(
1043
1080
  const rows = await loadWithOwnership(db, idWhere, ownership);
1044
1081
  const raw = rows[0];
1045
1082
  if (!raw) return null;
1046
- const row = rehydrateCompoundTypes(raw, entity);
1083
+ const row = decryptForRead(rehydrateCompoundTypes(raw, entity));
1047
1084
  const rowInfo = extractTableInfo(table);
1048
1085
  const coerced = coerceRow(row, rowInfo);
1049
1086
 
package/src/db/index.ts CHANGED
@@ -36,6 +36,11 @@ export {
36
36
  } from "./eagerload";
37
37
  export type { EncryptionProvider } from "./encryption";
38
38
  export { createEncryptionProvider } from "./encryption";
39
+ export {
40
+ collectEncryptedFieldNames,
41
+ decryptEntityFieldValues,
42
+ encryptEntityFieldValues,
43
+ } from "./entity-field-encryption";
39
44
  export type {
40
45
  BuildEntityTableMetaOptions,
41
46
  ColumnMeta,
@@ -0,0 +1,70 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { validateBoot } from "../boot-validator";
3
+ import { defineFeature } from "../define-feature";
4
+ import { createEntity, createTextField } from "../factories";
5
+
6
+ describe("validateBoot — i18n surface keys", () => {
7
+ test("passes when screen-derived keys are in r.translations", () => {
8
+ const feature = defineFeature("demo", (r) => {
9
+ r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
10
+ r.screen({
11
+ id: "item-list",
12
+ type: "entityList",
13
+ entity: "item",
14
+ columns: ["name"],
15
+ });
16
+ r.translations({
17
+ keys: {
18
+ "screen:item-list.title": { de: "Liste", en: "List" },
19
+ "demo:entity:item:field:name": { de: "Name", en: "Name" },
20
+ },
21
+ });
22
+ });
23
+ expect(() => validateBoot([feature])).not.toThrow();
24
+ });
25
+
26
+ test("throws when screen title key is missing", () => {
27
+ const feature = defineFeature("demo", (r) => {
28
+ r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
29
+ r.screen({
30
+ id: "item-list",
31
+ type: "entityList",
32
+ entity: "item",
33
+ columns: ["name"],
34
+ });
35
+ r.translations({
36
+ keys: {
37
+ "demo:entity:item:field:name": { de: "Name", en: "Name" },
38
+ },
39
+ });
40
+ });
41
+ expect(() => validateBoot([feature])).toThrow(
42
+ /required translation key missing: "screen:item-list.title"/,
43
+ );
44
+ });
45
+
46
+ test("skips features with no r.translations", () => {
47
+ const feature = defineFeature("legacy", (r) => {
48
+ r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
49
+ r.screen({
50
+ id: "item-list",
51
+ type: "entityList",
52
+ entity: "item",
53
+ columns: ["name"],
54
+ });
55
+ });
56
+ expect(() => validateBoot([feature])).not.toThrow();
57
+ });
58
+
59
+ test("throws when de/en locale is missing", () => {
60
+ const feature = defineFeature("demo", (r) => {
61
+ r.nav({ id: "home", label: "demo:nav.home" });
62
+ r.translations({
63
+ keys: {
64
+ "demo:nav.home": { de: "Start", en: "" },
65
+ },
66
+ });
67
+ });
68
+ expect(() => validateBoot([feature])).toThrow(/missing locale\(s\): en/);
69
+ });
70
+ });
@@ -1983,6 +1983,39 @@ describe("boot-validator", () => {
1983
1983
  });
1984
1984
  });
1985
1985
 
1986
+ // --- Tier 2.7e: rowAction rowClick (Row-Body-Klick) ---
1987
+ describe("entityList rowAction rowClick", () => {
1988
+ function makeFeature(rowClickCount: number) {
1989
+ return defineFeature("shop", (r) => {
1990
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
1991
+ r.screen({ id: "product-editor", type: "custom", renderer: { react: "stub" } });
1992
+ r.screen({
1993
+ id: "product-list",
1994
+ type: "entityList",
1995
+ entity: "product",
1996
+ columns: ["name"],
1997
+ rowActions: Array.from({ length: rowClickCount }, (_, i) => ({
1998
+ kind: "navigate" as const,
1999
+ id: `open-${i}`,
2000
+ label: "actions.edit",
2001
+ screen: "product-editor",
2002
+ rowClick: true,
2003
+ })),
2004
+ });
2005
+ });
2006
+ }
2007
+
2008
+ test("one rowClick navigate action passes boot", () => {
2009
+ expect(() => validateBoot([makeFeature(1)])).not.toThrow();
2010
+ });
2011
+
2012
+ test("more than one rowClick action per list is rejected", () => {
2013
+ expect(() => validateBoot([makeFeature(2)])).toThrow(
2014
+ /at most one may fire on a row-body click/i,
2015
+ );
2016
+ });
2017
+ });
2018
+
1986
2019
  // --- rowAction kind="writeHandler" handler-QN-Validierung (Tier 2.7e-1 erw.) ---
1987
2020
  describe("entityList rowAction kind=writeHandler handler-QN", () => {
1988
2021
  function makeFeature(handlerQn: string, register: boolean) {
@@ -205,6 +205,7 @@ describe("buildAppSchema", () => {
205
205
  id: "open",
206
206
  label: "Öffnen",
207
207
  screen: "detail",
208
+ rowClick: true,
208
209
  visible: { field: "status", ne: "archived" },
209
210
  },
210
211
  {
@@ -244,7 +245,11 @@ describe("buildAppSchema", () => {
244
245
  });
245
246
 
246
247
  // Explizit: FieldCondition-Varianten (eq, ne, boolean) landen unverändert an
247
- const actions = screen?.rowActions as Array<{ id: string; visible?: unknown }>;
248
+ const actions = screen?.rowActions as Array<{
249
+ id: string;
250
+ visible?: unknown;
251
+ rowClick?: unknown;
252
+ }>;
248
253
  expect(actions?.find((a) => a.id === "open")?.visible).toEqual({
249
254
  field: "status",
250
255
  ne: "archived",
@@ -254,6 +259,9 @@ describe("buildAppSchema", () => {
254
259
  eq: "open",
255
260
  });
256
261
  expect(actions?.find((a) => a.id === "always")?.visible).toBe(true);
262
+
263
+ // rowClick (skalares Flag) überlebt die JSON-Projektion — nicht gedroppt.
264
+ expect(actions?.find((a) => a.id === "open")?.rowClick).toBe(true);
257
265
  });
258
266
 
259
267
  test("derivedFields werden ins Client-Schema projiziert (valueType, ohne derive-fn)", () => {
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ ACTION_FORM_ENTITY,
4
+ fieldLabelKey,
5
+ requiredKeysFromScreen,
6
+ screenTitleKey,
7
+ } from "../../i18n/required-surface-keys";
8
+ import type { EntityListScreenDefinition } from "../types";
9
+
10
+ describe("requiredKeysFromScreen", () => {
11
+ test("entityList emits screen title + column field labels", () => {
12
+ const screen: EntityListScreenDefinition = {
13
+ id: "component-list",
14
+ type: "entityList",
15
+ entity: "component",
16
+ columns: ["name", { field: "status" }],
17
+ };
18
+ const keys = requiredKeysFromScreen("publicstatus", screen);
19
+ expect(keys).toContain(screenTitleKey("component-list"));
20
+ expect(keys).toContain(fieldLabelKey("publicstatus", "component", "name"));
21
+ expect(keys).toContain(fieldLabelKey("publicstatus", "component", "status"));
22
+ });
23
+
24
+ test("actionForm uses ACTION_FORM_ENTITY namespace", () => {
25
+ const keys = requiredKeysFromScreen("publicstatus", {
26
+ id: "incident-open-form",
27
+ type: "actionForm",
28
+ handler: "publicstatus:write:incident:open",
29
+ fields: {
30
+ title: { type: "text" },
31
+ },
32
+ layout: {
33
+ sections: [{ fields: ["title"] }],
34
+ },
35
+ });
36
+ expect(keys).toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "title"));
37
+ });
38
+ });
@@ -0,0 +1,30 @@
1
+ import {
2
+ buildEffectiveTranslationKeys,
3
+ findTranslationLocaleGaps,
4
+ requiredKeysFromFeature,
5
+ } from "../../i18n/required-surface-keys";
6
+ import type { FeatureDefinition } from "../types";
7
+
8
+ export function validateI18nSurfaceKeys(features: readonly FeatureDefinition[]): void {
9
+ const defined = buildEffectiveTranslationKeys(features);
10
+
11
+ for (const feature of features) {
12
+ // ponytail: features without r.translations are legacy — once they register
13
+ // keys, surface + locale checks apply (apps, new bundled work).
14
+ if (Object.keys(feature.translations ?? {}).length === 0) continue;
15
+
16
+ for (const key of requiredKeysFromFeature(feature)) {
17
+ if (!defined.has(key)) {
18
+ throw new Error(
19
+ `[i18n] Feature "${feature.name}": required translation key missing: "${key}"`,
20
+ );
21
+ }
22
+ }
23
+ }
24
+
25
+ for (const gap of findTranslationLocaleGaps(features)) {
26
+ throw new Error(
27
+ `[i18n] Feature "${gap.featureName}": key "${gap.key}" missing locale(s): ${gap.missingLocales.join(", ")}`,
28
+ );
29
+ }
30
+ }
@@ -26,6 +26,7 @@ import {
26
26
  validateTransitions,
27
27
  } from "./entity-handler";
28
28
  import { validateGdprHookCompleteness, validateGdprStoragePersistence } from "./gdpr-storage";
29
+ import { validateI18nSurfaceKeys } from "./i18n-keys";
29
30
  import { validateOwnershipRules } from "./ownership";
30
31
  import { validatePiiAndRetention } from "./pii-retention";
31
32
  import {
@@ -160,6 +161,7 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
160
161
 
161
162
  validateNavCycles(allNavQns);
162
163
  validateDefaultWorkspaceUniqueness(allWorkspaceQns);
164
+ validateI18nSurfaceKeys(features);
163
165
  validateExtensionPreSaveWiring(features);
164
166
  validateGdprStoragePersistence(features);
165
167
  validateGdprHookCompleteness(features);
@@ -465,6 +465,15 @@ export function validateScreens(
465
465
  rowMeta,
466
466
  );
467
467
  }
468
+ const rowClickActions = screen.rowActions.filter(
469
+ (a) => a.kind === "navigate" && a.rowClick === true,
470
+ );
471
+ if (rowClickActions.length > 1) {
472
+ throw new Error(
473
+ `[Feature ${feature.name}] Screen "${screenId}" (entityList) has ${rowClickActions.length} ` +
474
+ "rowActions marked rowClick:true — at most one may fire on a row-body click.",
475
+ );
476
+ }
468
477
  }
469
478
  // Tier 2.7e-2: toolbarActions — analog zu rowActions, aber bisher
470
479
  // ohne Validator. Typo'd navigate-targets und unregistrierte
@@ -219,6 +219,11 @@ export type RowActionNavigate = {
219
219
  /** Conditional Visibility pro Row. */
220
220
  readonly visible?: FieldCondition;
221
221
  readonly style?: "primary" | "secondary";
222
+ /** Wenn true, löst ein Klick auf die ganze Zeile (nicht nur das Aktionsmenü)
223
+ * diese navigate-Action aus. Max. eine pro Liste (Boot-Validator prüft). Nur
224
+ * auf navigate — ein Row-Klick darf keinen (evtl. destruktiven, unbestätigten)
225
+ * Write auslösen, daher nicht auf writeHandler-Actions. */
226
+ readonly rowClick?: boolean;
222
227
  };
223
228
 
224
229
  // ToolbarAction — Button im List-Header. Zwei Varianten: navigate auf
@@ -0,0 +1,214 @@
1
+ import type {
2
+ ActionFormScreenDefinition,
3
+ ConfigEditScreenDefinition,
4
+ EntityEditScreenDefinition,
5
+ EntityListScreenDefinition,
6
+ FeatureDefinition,
7
+ NavDefinition,
8
+ RowAction,
9
+ ScreenDefinition,
10
+ ToolbarAction,
11
+ WorkspaceDefinition,
12
+ } from "../engine/types";
13
+ import { isExtensionEditSection, normalizeListColumn } from "../engine/types/screen";
14
+
15
+ /** Pseudo-entity for actionForm field labels (renderer action-form-shim). */
16
+ export const ACTION_FORM_ENTITY = "__action-form__";
17
+
18
+ /** Pseudo-entity for configEdit field labels (renderer config-edit-shim). */
19
+ export const CONFIG_EDIT_ENTITY = "__config-edit__";
20
+
21
+ export function fieldLabelKey(featureName: string, entityName: string, fieldName: string): string {
22
+ return `${featureName}:entity:${entityName}:field:${fieldName}`;
23
+ }
24
+
25
+ export function screenTitleKey(screenId: string): string {
26
+ return `screen:${screenId}.title`;
27
+ }
28
+
29
+ function isI18nKey(value: string): boolean {
30
+ return value.includes(":");
31
+ }
32
+
33
+ function pushKey(out: Set<string>, value: string | undefined): void {
34
+ if (value !== undefined && isI18nKey(value)) out.add(value);
35
+ }
36
+
37
+ function editFieldName(f: string | { readonly field: string }): string {
38
+ return typeof f === "string" ? f : f.field;
39
+ }
40
+
41
+ function pushRowActionKeys(out: Set<string>, action: RowAction): void {
42
+ pushKey(out, action.label);
43
+ if (action.kind === "writeHandler" || action.kind === undefined) {
44
+ pushKey(out, action.confirm);
45
+ pushKey(out, action.confirmLabel);
46
+ }
47
+ }
48
+
49
+ function pushToolbarActionKeys(out: Set<string>, action: ToolbarAction): void {
50
+ pushKey(out, action.label);
51
+ if (action.kind === "writeHandler") {
52
+ pushKey(out, action.confirm);
53
+ pushKey(out, action.confirmLabel);
54
+ }
55
+ }
56
+
57
+ export function requiredKeysFromScreen(
58
+ featureName: string,
59
+ screen: ScreenDefinition,
60
+ ): readonly string[] {
61
+ const out = new Set<string>();
62
+ pushKey(out, screenTitleKey(screen.id));
63
+
64
+ switch (screen.type) {
65
+ case "entityList": {
66
+ const list = screen as EntityListScreenDefinition;
67
+ for (const col of list.columns) {
68
+ const normalized = normalizeListColumn(col);
69
+ if (normalized.label !== undefined) {
70
+ pushKey(out, normalized.label);
71
+ } else {
72
+ out.add(fieldLabelKey(featureName, list.entity, normalized.field));
73
+ }
74
+ }
75
+ for (const action of list.rowActions ?? []) pushRowActionKeys(out, action);
76
+ for (const action of list.toolbarActions ?? []) pushToolbarActionKeys(out, action);
77
+ break;
78
+ }
79
+ case "entityEdit": {
80
+ const edit = screen as EntityEditScreenDefinition;
81
+ pushKey(out, edit.submitLabel);
82
+ for (const section of edit.layout.sections) {
83
+ if (isExtensionEditSection(section)) {
84
+ pushKey(out, section.title);
85
+ continue;
86
+ }
87
+ pushKey(out, section.title);
88
+ for (const f of section.fields) {
89
+ const fieldName = editFieldName(f);
90
+ const override = edit.fieldLabels?.[fieldName];
91
+ if (override !== undefined) pushKey(out, override);
92
+ else out.add(fieldLabelKey(featureName, edit.entity, fieldName));
93
+ }
94
+ }
95
+ break;
96
+ }
97
+ case "actionForm": {
98
+ const form = screen as ActionFormScreenDefinition;
99
+ pushKey(out, form.submitLabel);
100
+ for (const fieldName of Object.keys(form.fields)) {
101
+ out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
102
+ }
103
+ for (const section of form.layout.sections) {
104
+ if (isExtensionEditSection(section)) {
105
+ pushKey(out, section.title);
106
+ continue;
107
+ }
108
+ pushKey(out, section.title);
109
+ for (const f of section.fields) {
110
+ const fieldName = editFieldName(f);
111
+ out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
112
+ }
113
+ }
114
+ break;
115
+ }
116
+ case "configEdit": {
117
+ const config = screen as ConfigEditScreenDefinition;
118
+ pushKey(out, config.submitLabel);
119
+ for (const fieldName of Object.keys(config.fields)) {
120
+ const override = config.fieldLabels?.[fieldName];
121
+ if (override !== undefined) pushKey(out, override);
122
+ else out.add(fieldLabelKey(featureName, CONFIG_EDIT_ENTITY, fieldName));
123
+ }
124
+ for (const section of config.layout.sections) {
125
+ if (isExtensionEditSection(section)) {
126
+ pushKey(out, section.title);
127
+ continue;
128
+ }
129
+ pushKey(out, section.title);
130
+ for (const f of section.fields) {
131
+ const fieldName = editFieldName(f);
132
+ const override = config.fieldLabels?.[fieldName];
133
+ if (override !== undefined) pushKey(out, override);
134
+ else out.add(fieldLabelKey(featureName, CONFIG_EDIT_ENTITY, fieldName));
135
+ }
136
+ }
137
+ break;
138
+ }
139
+ case "custom":
140
+ break;
141
+ }
142
+
143
+ return [...out];
144
+ }
145
+
146
+ export function requiredKeysFromNav(nav: NavDefinition): readonly string[] {
147
+ const out = new Set<string>();
148
+ pushKey(out, nav.label);
149
+ return [...out];
150
+ }
151
+
152
+ export function requiredKeysFromWorkspace(ws: WorkspaceDefinition): readonly string[] {
153
+ const out = new Set<string>();
154
+ pushKey(out, ws.label);
155
+ return [...out];
156
+ }
157
+
158
+ export function requiredKeysFromFeature(feature: FeatureDefinition): readonly string[] {
159
+ const out = new Set<string>();
160
+
161
+ for (const screen of Object.values(feature.screens)) {
162
+ for (const key of requiredKeysFromScreen(feature.name, screen)) out.add(key);
163
+ }
164
+ for (const nav of Object.values(feature.navs)) {
165
+ for (const key of requiredKeysFromNav(nav)) out.add(key);
166
+ }
167
+ for (const ws of Object.values(feature.workspaces)) {
168
+ for (const key of requiredKeysFromWorkspace(ws)) out.add(key);
169
+ }
170
+ for (const def of Object.values(feature.configKeys)) {
171
+ pushKey(out, def.mask?.title);
172
+ }
173
+
174
+ return [...out];
175
+ }
176
+
177
+ /** Effective lookup keys — mirrors registry merge (`feature:localKey` + raw full keys). */
178
+ export function buildEffectiveTranslationKeys(features: readonly FeatureDefinition[]): Set<string> {
179
+ const out = new Set<string>();
180
+ for (const feature of features) {
181
+ for (const key of Object.keys(feature.translations ?? {})) {
182
+ out.add(`${feature.name}:${key}`);
183
+ if (key.includes(":")) out.add(key);
184
+ }
185
+ }
186
+ return out;
187
+ }
188
+
189
+ export type TranslationLocaleGap = {
190
+ readonly featureName: string;
191
+ readonly key: string;
192
+ readonly missingLocales: readonly string[];
193
+ };
194
+
195
+ const REQUIRED_LOCALES = ["de", "en"] as const;
196
+
197
+ export function findTranslationLocaleGaps(
198
+ features: readonly FeatureDefinition[],
199
+ ): readonly TranslationLocaleGap[] {
200
+ const gaps: TranslationLocaleGap[] = [];
201
+ for (const feature of features) {
202
+ for (const [localKey, entry] of Object.entries(feature.translations ?? {})) {
203
+ const missing = REQUIRED_LOCALES.filter((locale) => (entry[locale] ?? "").length === 0);
204
+ if (missing.length > 0) {
205
+ gaps.push({
206
+ featureName: feature.name,
207
+ key: localKey,
208
+ missingLocales: missing,
209
+ });
210
+ }
211
+ }
212
+ }
213
+ return gaps;
214
+ }