@cosmicdrift/kumiko-framework 0.102.2 → 0.105.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.102.2",
3
+ "version": "0.105.0",
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.102.2",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.105.0",
185
185
  "bun-types": "^1.3.13",
186
186
  "pino-pretty": "^13.1.3"
187
187
  },
@@ -299,33 +299,17 @@ export type AuthRoutesConfig = {
299
299
 
300
300
  export type PasswordResetConfig = {
301
301
  // Qualified name of the request handler (the one that emits either
302
- // { kind: "reset-requested", ... } or { kind: "no-op" }).
302
+ // { kind: "reset-requested", ... } or { kind: "no-op" }). The handler
303
+ // builds the magic-link and sends the mail via delivery (ctx.notify) — the
304
+ // route only dispatches and returns the silent-success envelope.
303
305
  requestHandler: string;
304
306
  // Qualified name of the confirm handler (token + newPassword → set).
305
307
  confirmHandler: string;
306
- // Invoked only when the request handler returns kind=reset-requested.
307
- // Given the signed token + target email, the callback builds the URL
308
- // into the caller's app and hands it to whatever delivery channel the
309
- // app wires up. Errors bubble as 5xx so silent drop-on-send can't hide
310
- // an outgoing-mail outage behind a green response.
311
- sendResetEmail: (args: { email: string; resetUrl: string; expiresAt: string }) => Promise<void>;
312
- // Base URL of the app that hosts the reset form. The route appends
313
- // `?token=…` so you should NOT include a trailing `?` or `#`. Example:
314
- // "https://app.example.com/reset-password"
315
- appResetUrl: string;
316
308
  };
317
309
 
318
310
  export type EmailVerificationConfig = {
319
311
  requestHandler: string;
320
312
  confirmHandler: string;
321
- sendVerificationEmail: (args: {
322
- email: string;
323
- verificationUrl: string;
324
- expiresAt: string;
325
- }) => Promise<void>;
326
- // URL of the app page that receives the `?token=…` parameter and POSTs
327
- // it to /auth/verify-email on submit.
328
- appVerifyUrl: string;
329
313
  };
330
314
 
331
315
  // Tenant-Invite Magic-Link. Drei Accept-Branches für klare Separation:
@@ -334,28 +318,20 @@ export type EmailVerificationConfig = {
334
318
  // - signupCompleteHandler: anon User mit neuer email (Branch 3)
335
319
  // Branch 2+3 minten JWT analog signup-confirm.
336
320
  export type InviteConfig = {
337
- // Qualified handler names
321
+ // Qualified handler names. invite-create dispatches the invite mail itself
322
+ // via delivery (ctx.notify); the route layer only wires the accept branches.
338
323
  readonly acceptHandler: string;
339
324
  readonly acceptWithLoginHandler: string;
340
325
  readonly signupCompleteHandler: string;
341
- // Mail-Callback. Token-URL wird von der App-Page (z.B. /invite/accept)
342
- // an den User geschickt; der Frontend leitet je nach User-State (eingeloggt
343
- // / anon mit existing-email / anon mit neuer email) auf den passenden
344
- // Branch-Endpoint.
345
- readonly sendInviteEmail: (args: {
346
- email: string;
347
- inviteUrl: string;
348
- expiresAt: string;
349
- role: string;
350
- }) => Promise<void>;
351
- readonly appAcceptUrl: string;
352
326
  };
353
327
 
354
328
  // Magic-Link Self-Signup. Anders als reset/verify NICHT HMAC-signed —
355
- // der Token ist opaque random, Redis ist Source of Truth. Confirm
356
- // returnt `{ kind: "auth-session", session, tenantKey }` analog zu
357
- // loginHandler, sodass die Route JWT minten + Cookies setzen kann
358
- // (Auto-Login direkt nach Activation, kein zweiter login-Roundtrip).
329
+ // der Token ist opaque random, Redis ist Source of Truth. The request
330
+ // handler dispatches the activation mail via delivery (ctx.notify); the
331
+ // route only forwards the dispatch. Confirm returnt
332
+ // `{ kind: "auth-session", session, tenantKey }` analog zu loginHandler,
333
+ // sodass die Route JWT minten + Cookies setzen kann (Auto-Login direkt
334
+ // nach Activation, kein zweiter login-Roundtrip).
359
335
  export type SignupConfig = {
360
336
  // Qualified name of the request handler (typisch
361
337
  // AuthHandlers.signupRequest).
@@ -364,16 +340,6 @@ export type SignupConfig = {
364
340
  // AuthHandlers.signupConfirm). Returnt SessionUser-Shape — die
365
341
  // Route wickelt das wie einen erfolgreichen login.
366
342
  confirmHandler: string;
367
- // Mail-Callback. Token-URL wird als `${appActivationUrl}?token=…`
368
- // an die App-Page geleitet.
369
- sendActivationEmail: (args: {
370
- email: string;
371
- activationUrl: string;
372
- expiresAt: string;
373
- }) => Promise<void>;
374
- // Base URL of the app page that receives the `?token=…` parameter
375
- // (typisch /signup/complete). KEIN trailing `?` oder `#`.
376
- appActivationUrl: string;
377
343
  };
378
344
 
379
345
  // Extract `ip` and `user-agent` for the sessionCreator.
@@ -560,10 +526,6 @@ export function createAuthRoutes(
560
526
  dispatcher,
561
527
  path: Routes.authRequestPasswordReset,
562
528
  requestHandler: pr.requestHandler,
563
- successKind: "reset-requested",
564
- appBaseUrl: pr.appResetUrl,
565
- sendEmail: ({ email, url, expiresAt }) =>
566
- pr.sendResetEmail({ email, resetUrl: url, expiresAt }), // @wrapper-known semantic-alias
567
529
  });
568
530
  registerTokenConfirmRoute({
569
531
  api,
@@ -582,10 +544,6 @@ export function createAuthRoutes(
582
544
  dispatcher,
583
545
  path: Routes.authRequestEmailVerification,
584
546
  requestHandler: ev.requestHandler,
585
- successKind: "verification-requested",
586
- appBaseUrl: ev.appVerifyUrl,
587
- sendEmail: ({ email, url, expiresAt }) =>
588
- ev.sendVerificationEmail({ email, verificationUrl: url, expiresAt }), // @wrapper-known semantic-alias
589
547
  });
590
548
  registerTokenConfirmRoute({
591
549
  api,
@@ -607,10 +565,6 @@ export function createAuthRoutes(
607
565
  dispatcher,
608
566
  path: Routes.authSignupRequest,
609
567
  requestHandler: sg.requestHandler,
610
- successKind: "signup-requested",
611
- appBaseUrl: sg.appActivationUrl,
612
- sendEmail: ({ email, url, expiresAt }) =>
613
- sg.sendActivationEmail({ email, activationUrl: url, expiresAt }), // @wrapper-known semantic-alias
614
568
  });
615
569
 
616
570
  api.post(Routes.authSignupConfirm, async (c) => {
@@ -955,37 +909,19 @@ export function createAuthRoutes(
955
909
  }
956
910
 
957
911
  // --- shared route builders for token flows ---------------------------------
958
- // Password-reset and email-verification share the exact same HTTP-shape:
959
- // request-route emits a token optional sendEmail callbacksilent-success,
960
- // confirm-route validates token + does the state change typed failure or
961
- // 200. Before this extraction both flows carried ~45 LOC of nearly-identical
962
- // body-parse / dispatch / url-build / response plumbing. The helpers keep
963
- // the public-facing silent-success invariant in one place changing how
964
- // the framework handles "invalid_body" on a public token endpoint is now
965
- // one edit, not two.
966
-
967
- type TokenRequestData = {
968
- kind: string;
969
- email: string;
970
- token: string;
971
- expiresAt: string;
972
- };
973
-
974
- type TokenNoOp = { kind: "no-op" };
912
+ // Password-reset, email-verification and signup share the exact same request
913
+ // HTTP-shape: parse bodydispatch the request handler always-200 (no
914
+ // enumeration). The handler mints the token AND dispatches the magic-link mail
915
+ // via delivery (ctx.notify); the route never sees the token. confirm-route
916
+ // validates the token + does the state change typed failure or 200. Keeping
917
+ // the silent-success invariant in one place means changing how the framework
918
+ // handles "invalid_body" on a public token endpoint is one edit, not three.
975
919
 
976
920
  function registerTokenRequestRoute(opts: {
977
921
  api: Hono;
978
922
  dispatcher: Dispatcher;
979
923
  path: string;
980
924
  requestHandler: string;
981
- // Discriminator the feature handler emits when it actually minted a token
982
- // (vs. the silent no-op for unknown/already-handled users).
983
- successKind: string;
984
- // Base URL of the receiving app page. `?token=…` is appended with proper
985
- // separator handling so the caller's URL may or may not carry existing
986
- // query params.
987
- appBaseUrl: string;
988
- sendEmail: (args: { email: string; url: string; expiresAt: string }) => Promise<void>;
989
925
  }): void {
990
926
  const body = RequestTokenBody;
991
927
  opts.api.post(opts.path, async (c) => {
@@ -995,30 +931,11 @@ function registerTokenRequestRoute(opts: {
995
931
  // anything from the shape of their input.
996
932
  if (!parsed.success) return c.json({ isSuccess: true });
997
933
 
998
- const result = await opts.dispatcher.write(
999
- opts.requestHandler,
1000
- { email: parsed.data.email },
1001
- GUEST_USER,
1002
- );
1003
-
934
+ // The handler dispatches the magic-link mail via delivery before returning.
1004
935
  // Handler-level failures (only legitimate reason: misconfiguration) are
1005
- // silently swallowed — observability logs capture them for ops.
1006
- if (result.isSuccess) {
1007
- // @cast-boundary engine-payload — generic dispatcher.write result narrowed by handler-emitted kind
1008
- const data = result.data as TokenRequestData | TokenNoOp;
1009
- if (data.kind === opts.successKind) {
1010
- // TS narrowt nicht durch generic successKind (string, kein literal) —
1011
- // die kind-Gleichheit garantiert den TokenRequestData-Branch hier.
1012
- const requested = data as TokenRequestData; // @cast-boundary engine-payload
1013
- const sep = opts.appBaseUrl.includes("?") ? "&" : "?";
1014
- const url = `${opts.appBaseUrl}${sep}token=${encodeURIComponent(requested.token)}`;
1015
- await opts.sendEmail({
1016
- email: requested.email,
1017
- url,
1018
- expiresAt: requested.expiresAt,
1019
- });
1020
- }
1021
- }
936
+ // silently swallowed — observability logs capture them for ops — so the
937
+ // response shape stays uniform for unknown vs. known emails.
938
+ await opts.dispatcher.write(opts.requestHandler, { email: parsed.data.email }, GUEST_USER);
1022
939
 
1023
940
  return c.json({ isSuccess: true });
1024
941
  });
@@ -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
+ }