@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 +2 -2
- package/src/api/auth-routes.ts +22 -105
- package/src/db/__tests__/entity-field-encryption.test.ts +45 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +73 -0
- package/src/db/entity-field-encryption.ts +81 -0
- package/src/db/event-store-executor.ts +44 -7
- package/src/db/index.ts +5 -0
- package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +70 -0
- package/src/engine/__tests__/boot-validator.test.ts +33 -0
- package/src/engine/__tests__/build-app-schema.test.ts +9 -1
- package/src/engine/__tests__/required-surface-keys.test.ts +38 -0
- package/src/engine/boot-validator/i18n-keys.ts +30 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/boot-validator/screens-nav.ts +9 -0
- package/src/engine/types/screen.ts +5 -0
- package/src/i18n/required-surface-keys.ts +214 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.105.0",
|
|
185
185
|
"bun-types": "^1.3.13",
|
|
186
186
|
"pino-pretty": "^13.1.3"
|
|
187
187
|
},
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -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.
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
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
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
//
|
|
963
|
-
// the
|
|
964
|
-
//
|
|
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 body → dispatch 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
|
-
|
|
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
|
-
|
|
1007
|
-
|
|
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 =
|
|
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) =>
|
|
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<{
|
|
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
|
+
}
|