@cosmicdrift/kumiko-framework 0.125.2 → 0.127.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-middleware.ts +96 -6
- package/src/api/auth-routes.ts +2 -0
- package/src/api/index.ts +1 -0
- package/src/api/server.ts +3 -0
- package/src/engine/__tests__/boot-validator-entity-list.test.ts +104 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +87 -1
- package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +26 -5
- package/src/engine/__tests__/boot-validator.test.ts +7 -2
- package/src/engine/__tests__/nav.test.ts +6 -1
- package/src/engine/__tests__/screen.test.ts +6 -1
- package/src/engine/boot-validator/entity-list-screens.ts +88 -0
- package/src/engine/boot-validator/gdpr-storage.ts +52 -3
- package/src/engine/boot-validator/i18n-keys.ts +58 -4
- package/src/engine/boot-validator/index.ts +4 -0
- package/src/engine/build-config-feature-schema.ts +1 -0
- package/src/engine/extensions/tenant-data.ts +19 -0
- package/src/engine/index.ts +5 -0
- package/src/engine/types/projection.ts +5 -0
- package/src/engine/types/screen.ts +2 -0
- package/src/i18n/__tests__/required-surface-keys.test.ts +17 -0
- package/src/i18n/required-surface-keys.ts +54 -0
- package/src/testing/boot-validator-fixture.ts +122 -0
- package/src/testing/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.127.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>",
|
|
@@ -189,7 +189,7 @@
|
|
|
189
189
|
"zod": "^4.4.3"
|
|
190
190
|
},
|
|
191
191
|
"devDependencies": {
|
|
192
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
192
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.127.0",
|
|
193
193
|
"bun-types": "^1.3.13",
|
|
194
194
|
"pino-pretty": "^13.1.3"
|
|
195
195
|
},
|
|
@@ -55,6 +55,10 @@ export type AuthSessionChecker = (
|
|
|
55
55
|
// and short-circuits the JWT path on a hit.
|
|
56
56
|
export type PatResolver = (rawToken: string) => Promise<SessionUser | null>;
|
|
57
57
|
|
|
58
|
+
export type TenantLifecycleStatusResolver = (
|
|
59
|
+
tenantId: TenantId,
|
|
60
|
+
) => Promise<{ readonly status: string } | null>;
|
|
61
|
+
|
|
58
62
|
export type AuthMiddlewareOptions = {
|
|
59
63
|
// Called after JWT-verify when the token carries a sid. If the checker
|
|
60
64
|
// reports anything other than "live", the request is rejected with 401.
|
|
@@ -74,6 +78,10 @@ export type AuthMiddlewareOptions = {
|
|
|
74
78
|
// a SessionUser with id="anonymous" and roles=["anonymous"], scoped to a
|
|
75
79
|
// tenantId resolved through the chain documented on AnonymousAccessConfig.
|
|
76
80
|
readonly anonymousAccess?: AnonymousAccessConfig;
|
|
81
|
+
// Consulted after tenantId is resolved (JWT/PAT/anonymous). Returns 410
|
|
82
|
+
// when the tenant is in teardown (destroyRequested/destroying/destroyed).
|
|
83
|
+
// cancel-destruction is exempt while status=destroyRequested.
|
|
84
|
+
readonly resolveTenantLifecycleStatus?: TenantLifecycleStatusResolver;
|
|
77
85
|
};
|
|
78
86
|
|
|
79
87
|
// Resolves the tenant for an unauthenticated request. Returns null when no
|
|
@@ -127,14 +135,15 @@ type MiddlewareRejectCode =
|
|
|
127
135
|
| "tenant_required"
|
|
128
136
|
| "tenant_not_found"
|
|
129
137
|
| "tenant_mismatch"
|
|
130
|
-
| "invalid_tenant_format"
|
|
138
|
+
| "invalid_tenant_format"
|
|
139
|
+
| "tenant_unavailable";
|
|
131
140
|
|
|
132
141
|
// @wrapper-known error-helper
|
|
133
142
|
function middlewareReject(
|
|
134
143
|
c: Context,
|
|
135
144
|
opts: {
|
|
136
145
|
code: MiddlewareRejectCode;
|
|
137
|
-
status: 400 | 401 | 403 | 404;
|
|
146
|
+
status: 400 | 401 | 403 | 404 | 410;
|
|
138
147
|
message: string;
|
|
139
148
|
i18nKey: string;
|
|
140
149
|
details?: Record<string, unknown>;
|
|
@@ -187,7 +196,13 @@ function extractToken(
|
|
|
187
196
|
}
|
|
188
197
|
|
|
189
198
|
export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions = {}) {
|
|
190
|
-
const {
|
|
199
|
+
const {
|
|
200
|
+
sessionChecker,
|
|
201
|
+
strictMode = false,
|
|
202
|
+
anonymousAccess,
|
|
203
|
+
patResolver,
|
|
204
|
+
resolveTenantLifecycleStatus,
|
|
205
|
+
} = options;
|
|
191
206
|
|
|
192
207
|
return async (c: Context, next: Next) => {
|
|
193
208
|
const extracted = extractToken(c);
|
|
@@ -219,7 +234,7 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
|
|
|
219
234
|
i18nKey: "auth.errors.missingToken",
|
|
220
235
|
});
|
|
221
236
|
}
|
|
222
|
-
return await handleAnonymous(c, anonymousAccess, next);
|
|
237
|
+
return await handleAnonymous(c, anonymousAccess, next, resolveTenantLifecycleStatus);
|
|
223
238
|
}
|
|
224
239
|
return middlewareReject(c, {
|
|
225
240
|
code: "missing_token",
|
|
@@ -234,7 +249,7 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
|
|
|
234
249
|
// Personal Access Token, not a JWT. Short-circuit the JWT path entirely.
|
|
235
250
|
// Cookie transport is never a PAT (the browser holds the JWT).
|
|
236
251
|
if (patResolver && transport === "bearer" && token.startsWith(PAT_TOKEN_PREFIX)) {
|
|
237
|
-
return await handlePat(c, patResolver, token, next);
|
|
252
|
+
return await handlePat(c, patResolver, token, next, resolveTenantLifecycleStatus);
|
|
238
253
|
}
|
|
239
254
|
|
|
240
255
|
let payload: Awaited<ReturnType<JwtHelper["verify"]>>;
|
|
@@ -286,6 +301,12 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
|
|
|
286
301
|
...(payload.claims ? { claims: payload.claims } : {}),
|
|
287
302
|
...(payload.jti ? { sid: payload.jti } : {}),
|
|
288
303
|
};
|
|
304
|
+
const lifecycleReject = await rejectIfTenantTeardown(
|
|
305
|
+
c,
|
|
306
|
+
payload.tenantId,
|
|
307
|
+
resolveTenantLifecycleStatus,
|
|
308
|
+
);
|
|
309
|
+
if (lifecycleReject) return lifecycleReject;
|
|
289
310
|
c.set(USER_KEY, user);
|
|
290
311
|
c.set(AUTH_TRANSPORT_KEY, transport);
|
|
291
312
|
await next();
|
|
@@ -311,6 +332,7 @@ async function handlePat(
|
|
|
311
332
|
patResolver: PatResolver,
|
|
312
333
|
token: string,
|
|
313
334
|
next: Next,
|
|
335
|
+
resolveTenantLifecycleStatus?: TenantLifecycleStatusResolver,
|
|
314
336
|
): Promise<Response | undefined> {
|
|
315
337
|
const patUser = await patResolver(token);
|
|
316
338
|
if (!patUser) {
|
|
@@ -335,6 +357,12 @@ async function handlePat(
|
|
|
335
357
|
}
|
|
336
358
|
c.set(USER_KEY, patUser);
|
|
337
359
|
c.set(AUTH_TRANSPORT_KEY, "bearer");
|
|
360
|
+
const lifecycleReject = await rejectIfTenantTeardown(
|
|
361
|
+
c,
|
|
362
|
+
patUser.tenantId,
|
|
363
|
+
resolveTenantLifecycleStatus,
|
|
364
|
+
);
|
|
365
|
+
if (lifecycleReject) return lifecycleReject;
|
|
338
366
|
await next();
|
|
339
367
|
// skip: PAT path completed — next() ran; explicit return keeps the
|
|
340
368
|
// Response|undefined union honest (same as handleAnonymous).
|
|
@@ -357,6 +385,7 @@ async function handleAnonymous(
|
|
|
357
385
|
c: Context,
|
|
358
386
|
config: AnonymousAccessConfig,
|
|
359
387
|
next: Next,
|
|
388
|
+
resolveTenantLifecycleStatus?: TenantLifecycleStatusResolver,
|
|
360
389
|
): Promise<Response | undefined> {
|
|
361
390
|
// Step 1+2: parse client-supplied tenant. Reject malformed values before
|
|
362
391
|
// they touch any downstream consumer (DB, cache, audit row).
|
|
@@ -394,6 +423,12 @@ async function handleAnonymous(
|
|
|
394
423
|
}
|
|
395
424
|
|
|
396
425
|
// Step 5: synthesise + continue.
|
|
426
|
+
const lifecycleReject = await rejectIfTenantTeardown(
|
|
427
|
+
c,
|
|
428
|
+
resolved.tenantId,
|
|
429
|
+
resolveTenantLifecycleStatus,
|
|
430
|
+
);
|
|
431
|
+
if (lifecycleReject) return lifecycleReject;
|
|
397
432
|
c.set(USER_KEY, createAnonymousUser(resolved.tenantId));
|
|
398
433
|
await next();
|
|
399
434
|
// skip: anonymous path completed — Hono middleware contract returns void
|
|
@@ -479,8 +514,63 @@ async function resolveTenant(
|
|
|
479
514
|
|
|
480
515
|
type RejectArgs = {
|
|
481
516
|
code: MiddlewareRejectCode;
|
|
482
|
-
status: 400 | 401 | 403 | 404;
|
|
517
|
+
status: 400 | 401 | 403 | 404 | 410;
|
|
483
518
|
message: string;
|
|
484
519
|
i18nKey: string;
|
|
485
520
|
details?: Record<string, unknown>;
|
|
486
521
|
};
|
|
522
|
+
|
|
523
|
+
const TENANT_LIFECYCLE_BLOCKED = new Set([
|
|
524
|
+
"destroyRequested",
|
|
525
|
+
"destroying",
|
|
526
|
+
"destroyFailed",
|
|
527
|
+
"destroyed",
|
|
528
|
+
]);
|
|
529
|
+
const TENANT_LIFECYCLE_CANCEL_QN = "tenant-lifecycle:write:cancel-destruction";
|
|
530
|
+
|
|
531
|
+
async function requestsCancelDestruction(c: Context): Promise<boolean> {
|
|
532
|
+
if (c.req.method !== "POST") return false;
|
|
533
|
+
try {
|
|
534
|
+
const path = c.req.path;
|
|
535
|
+
const body = (await c.req.raw.clone().json()) as {
|
|
536
|
+
type?: string;
|
|
537
|
+
commands?: Array<{ type?: string }>;
|
|
538
|
+
};
|
|
539
|
+
if (path === "/api/write") {
|
|
540
|
+
return body.type === TENANT_LIFECYCLE_CANCEL_QN;
|
|
541
|
+
}
|
|
542
|
+
if (path === "/api/batch") {
|
|
543
|
+
// every(), not some(): a batch mixing the cancel command with other
|
|
544
|
+
// writes must NOT wave the whole batch through the teardown gate —
|
|
545
|
+
// only a batch consisting solely of cancel-destruction is exempt.
|
|
546
|
+
return (
|
|
547
|
+
Array.isArray(body.commands) &&
|
|
548
|
+
body.commands.length > 0 &&
|
|
549
|
+
body.commands.every((command) => command.type === TENANT_LIFECYCLE_CANCEL_QN)
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
} catch {
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function rejectIfTenantTeardown(
|
|
559
|
+
c: Context,
|
|
560
|
+
tenantId: TenantId,
|
|
561
|
+
resolveTenantLifecycleStatus: TenantLifecycleStatusResolver | undefined,
|
|
562
|
+
): Promise<Response | undefined> {
|
|
563
|
+
if (!resolveTenantLifecycleStatus) return undefined;
|
|
564
|
+
const lifecycle = await resolveTenantLifecycleStatus(tenantId);
|
|
565
|
+
if (!lifecycle || !TENANT_LIFECYCLE_BLOCKED.has(lifecycle.status)) return undefined;
|
|
566
|
+
if (lifecycle.status === "destroyRequested" && (await requestsCancelDestruction(c))) {
|
|
567
|
+
return undefined;
|
|
568
|
+
}
|
|
569
|
+
return middlewareReject(c, {
|
|
570
|
+
code: "tenant_unavailable",
|
|
571
|
+
status: 410,
|
|
572
|
+
message: `tenant "${tenantId}" is unavailable (${lifecycle.status})`,
|
|
573
|
+
i18nKey: "auth.errors.tenantUnavailable",
|
|
574
|
+
details: { tenantId, status: lifecycle.status },
|
|
575
|
+
});
|
|
576
|
+
}
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -243,6 +243,8 @@ export type AuthRoutesConfig = {
|
|
|
243
243
|
// Wired by run-prod-app when the PAT feature is mounted; unwired = no PAT
|
|
244
244
|
// rate limiting.
|
|
245
245
|
patRateLimiter?: LoginRateLimiter;
|
|
246
|
+
// Tenant-lifecycle 410 gate — wired by tenant-lifecycle / run-prod-app.
|
|
247
|
+
resolveTenantLifecycleStatus?: import("./auth-middleware").TenantLifecycleStatusResolver;
|
|
246
248
|
// Password-reset flow. When wired, POST /auth/request-password-reset and
|
|
247
249
|
// POST /auth/reset-password are mounted as public routes. The framework
|
|
248
250
|
// dispatches to the feature-level handlers (authoring QNs typically come
|
package/src/api/index.ts
CHANGED
package/src/api/server.ts
CHANGED
|
@@ -567,6 +567,9 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
567
567
|
...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
|
|
568
568
|
...(options.auth?.sessionStrictMode ? { strictMode: options.auth.sessionStrictMode } : {}),
|
|
569
569
|
...(options.auth?.patResolver ? { patResolver: options.auth.patResolver } : {}),
|
|
570
|
+
...(options.auth?.resolveTenantLifecycleStatus
|
|
571
|
+
? { resolveTenantLifecycleStatus: options.auth.resolveTenantLifecycleStatus }
|
|
572
|
+
: {}),
|
|
570
573
|
...(options.anonymousAccess ? { anonymousAccess: options.anonymousAccess } : {}),
|
|
571
574
|
});
|
|
572
575
|
app.use("/api/*", async (c, next) => {
|
|
@@ -0,0 +1,104 @@
|
|
|
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 — entityList screens", () => {
|
|
7
|
+
test("requires defaultSort when searchable", () => {
|
|
8
|
+
const feature = defineFeature("demo", (r) => {
|
|
9
|
+
r.entity(
|
|
10
|
+
"item",
|
|
11
|
+
createEntity({
|
|
12
|
+
table: "Items",
|
|
13
|
+
fields: { name: createTextField({ sortable: true }) },
|
|
14
|
+
}),
|
|
15
|
+
);
|
|
16
|
+
r.screen({
|
|
17
|
+
id: "item-list",
|
|
18
|
+
type: "entityList",
|
|
19
|
+
entity: "item",
|
|
20
|
+
columns: ["name"],
|
|
21
|
+
});
|
|
22
|
+
r.translations({
|
|
23
|
+
keys: {
|
|
24
|
+
"screen:item-list.title": { de: "Liste", en: "List" },
|
|
25
|
+
"demo:entity:item:field:name": { de: "Name", en: "Name" },
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
expect(() => validateBoot([feature])).toThrow(/defaultSort required/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("rejects searchable:false on operator lists not on whitelist", () => {
|
|
33
|
+
const feature = defineFeature("demo", (r) => {
|
|
34
|
+
r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
|
|
35
|
+
r.screen({
|
|
36
|
+
id: "item-list",
|
|
37
|
+
type: "entityList",
|
|
38
|
+
entity: "item",
|
|
39
|
+
columns: ["name"],
|
|
40
|
+
searchable: false,
|
|
41
|
+
});
|
|
42
|
+
r.translations({
|
|
43
|
+
keys: {
|
|
44
|
+
"screen:item-list.title": { de: "Liste", en: "List" },
|
|
45
|
+
"demo:entity:item:field:name": { de: "Name", en: "Name" },
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
expect(() => validateBoot([feature])).toThrow(/searchable defaults to true/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("allows searchable:false on download-attempt-list whitelist", () => {
|
|
53
|
+
const feature = defineFeature("demo", (r) => {
|
|
54
|
+
r.entity("attempt", createEntity({ table: "Attempts", fields: { id: createTextField() } }));
|
|
55
|
+
r.screen({
|
|
56
|
+
id: "download-attempt-list",
|
|
57
|
+
type: "entityList",
|
|
58
|
+
entity: "attempt",
|
|
59
|
+
columns: ["id"],
|
|
60
|
+
searchable: false,
|
|
61
|
+
});
|
|
62
|
+
r.translations({
|
|
63
|
+
keys: {
|
|
64
|
+
"screen:download-attempt-list.title": { de: "Liste", en: "List" },
|
|
65
|
+
"demo:entity:attempt:field:id": { de: "ID", en: "ID" },
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("requires navigate rowAction when entityEdit exists", () => {
|
|
73
|
+
const feature = defineFeature("demo", (r) => {
|
|
74
|
+
r.entity(
|
|
75
|
+
"item",
|
|
76
|
+
createEntity({
|
|
77
|
+
table: "Items",
|
|
78
|
+
fields: { name: createTextField({ sortable: true }) },
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
r.screen({
|
|
82
|
+
id: "item-list",
|
|
83
|
+
type: "entityList",
|
|
84
|
+
entity: "item",
|
|
85
|
+
columns: ["name"],
|
|
86
|
+
defaultSort: { field: "name", dir: "asc" },
|
|
87
|
+
});
|
|
88
|
+
r.screen({
|
|
89
|
+
id: "item-edit",
|
|
90
|
+
type: "entityEdit",
|
|
91
|
+
entity: "item",
|
|
92
|
+
layout: { sections: [{ fields: ["name"] }] },
|
|
93
|
+
});
|
|
94
|
+
r.translations({
|
|
95
|
+
keys: {
|
|
96
|
+
"screen:item-list.title": { de: "Liste", en: "List" },
|
|
97
|
+
"screen:item-edit.title": { de: "Edit", en: "Edit" },
|
|
98
|
+
"demo:entity:item:field:name": { de: "Name", en: "Name" },
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
expect(() => validateBoot([feature])).toThrow(/navigate rowAction/);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -5,15 +5,18 @@
|
|
|
5
5
|
// hook but no delete hook (Art.17 violation).
|
|
6
6
|
// V3 — PII-entity-without-hook guard. Catches entities with pii/userOwned
|
|
7
7
|
// fields that no feature registers an EXT_USER_DATA hook for.
|
|
8
|
+
// V4 — tenantOwned-entity-without-hook guard. Mirrors V3 for EXT_TENANT_DATA.
|
|
8
9
|
|
|
9
10
|
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
11
|
+
import { integer, type SchemaTable, table, uuid } from "../../db/dialect";
|
|
10
12
|
import {
|
|
11
13
|
validateGdprHookCompleteness,
|
|
12
14
|
validateGdprPiiHookCoverage,
|
|
13
15
|
validateGdprStoragePersistence,
|
|
16
|
+
validateTenantDataHookCoverage,
|
|
14
17
|
} from "../boot-validator/gdpr-storage";
|
|
15
18
|
import { defineFeature } from "../define-feature";
|
|
16
|
-
import { EXT_USER_DATA } from "../extension-names";
|
|
19
|
+
import { EXT_TENANT_DATA, EXT_USER_DATA } from "../extension-names";
|
|
17
20
|
import { createEntity, createLongTextField, createTextField } from "../factories";
|
|
18
21
|
|
|
19
22
|
const udr = () => defineFeature("user-data-rights", () => {});
|
|
@@ -22,6 +25,13 @@ const fileProvider = (name: string) =>
|
|
|
22
25
|
r.useExtension("fileProvider", name);
|
|
23
26
|
});
|
|
24
27
|
|
|
28
|
+
// Throwaway Drizzle table for r.projection() registrations — the projection
|
|
29
|
+
// runtime behaviour isn't under test here, only the guard's field scan.
|
|
30
|
+
const testProjectionTable = table("test_projection", {
|
|
31
|
+
id: uuid("id").primaryKey(),
|
|
32
|
+
count: integer("count").notNull().default(0),
|
|
33
|
+
}) as unknown as SchemaTable;
|
|
34
|
+
|
|
25
35
|
const S3_ENV = ["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY", "S3_SECRET_KEY"] as const;
|
|
26
36
|
|
|
27
37
|
// V2/V3 are hard boot gates now — capture the thrown message so a single case
|
|
@@ -218,3 +228,79 @@ describe("validateGdprPiiHookCoverage (V3)", () => {
|
|
|
218
228
|
expect(msg).toContain('"note"');
|
|
219
229
|
});
|
|
220
230
|
});
|
|
231
|
+
|
|
232
|
+
describe("validateTenantDataHookCoverage (V4)", () => {
|
|
233
|
+
const destroyFn = async () => {};
|
|
234
|
+
const tenantLifecycle = () => defineFeature("tenant-lifecycle", () => {});
|
|
235
|
+
|
|
236
|
+
const tenantEntityFeature = () =>
|
|
237
|
+
defineFeature("billing", (r) => {
|
|
238
|
+
r.entity(
|
|
239
|
+
"subscription",
|
|
240
|
+
createEntity({
|
|
241
|
+
fields: { providerCustomerId: createTextField({ tenantOwned: true }) },
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("tenant-lifecycle not mounted → no throw", () => {
|
|
247
|
+
expect(() => validateTenantDataHookCoverage([tenantEntityFeature()])).not.toThrow();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("tenantOwned entity without any EXT_TENANT_DATA hook → throws naming entity and field", () => {
|
|
251
|
+
const msg = catchMessage(() =>
|
|
252
|
+
validateTenantDataHookCoverage([tenantLifecycle(), tenantEntityFeature()]),
|
|
253
|
+
);
|
|
254
|
+
expect(msg).toContain('"subscription"');
|
|
255
|
+
expect(msg).toContain("providerCustomerId");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("tenantOwned entity with hook registered → no throw", () => {
|
|
259
|
+
const hooks = defineFeature("billing-hooks", (r) => {
|
|
260
|
+
r.useExtension(EXT_TENANT_DATA, "subscription", { destroy: destroyFn });
|
|
261
|
+
});
|
|
262
|
+
expect(() =>
|
|
263
|
+
validateTenantDataHookCoverage([tenantLifecycle(), tenantEntityFeature(), hooks]),
|
|
264
|
+
).not.toThrow();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Regression: billing-foundation's real `subscription` shape — r.projection()
|
|
268
|
+
// (no executor, no r.entity) carrying an `entity` reference for its
|
|
269
|
+
// tenantOwned fields. Before this fix, feature.entities-only scanning made
|
|
270
|
+
// this shape invisible to the guard.
|
|
271
|
+
test("tenantOwned field on a projection-only entity (no r.entity) is still caught", () => {
|
|
272
|
+
const projectionFeature = defineFeature("billing", (r) => {
|
|
273
|
+
r.projection({
|
|
274
|
+
name: "subscription",
|
|
275
|
+
source: "subscription",
|
|
276
|
+
table: testProjectionTable,
|
|
277
|
+
entity: createEntity({
|
|
278
|
+
fields: { providerCustomerId: createTextField({ tenantOwned: true }) },
|
|
279
|
+
}),
|
|
280
|
+
apply: {},
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
const msg = catchMessage(() =>
|
|
284
|
+
validateTenantDataHookCoverage([tenantLifecycle(), projectionFeature]),
|
|
285
|
+
);
|
|
286
|
+
expect(msg).toContain("providerCustomerId");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("tenantOwned field on a projection-only entity WITH a hook → no throw", () => {
|
|
290
|
+
const projectionFeature = defineFeature("billing", (r) => {
|
|
291
|
+
r.projection({
|
|
292
|
+
name: "subscription",
|
|
293
|
+
source: "subscription",
|
|
294
|
+
table: testProjectionTable,
|
|
295
|
+
entity: createEntity({
|
|
296
|
+
fields: { providerCustomerId: createTextField({ tenantOwned: true }) },
|
|
297
|
+
}),
|
|
298
|
+
apply: {},
|
|
299
|
+
});
|
|
300
|
+
r.useExtension(EXT_TENANT_DATA, "subscription", { destroy: destroyFn });
|
|
301
|
+
});
|
|
302
|
+
expect(() =>
|
|
303
|
+
validateTenantDataHookCoverage([tenantLifecycle(), projectionFeature]),
|
|
304
|
+
).not.toThrow();
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -6,12 +6,19 @@ import { createEntity, createTextField } from "../factories";
|
|
|
6
6
|
describe("validateBoot — i18n surface keys", () => {
|
|
7
7
|
test("passes when screen-derived keys are in r.translations", () => {
|
|
8
8
|
const feature = defineFeature("demo", (r) => {
|
|
9
|
-
r.entity(
|
|
9
|
+
r.entity(
|
|
10
|
+
"item",
|
|
11
|
+
createEntity({
|
|
12
|
+
table: "Items",
|
|
13
|
+
fields: { name: createTextField({ sortable: true }) },
|
|
14
|
+
}),
|
|
15
|
+
);
|
|
10
16
|
r.screen({
|
|
11
17
|
id: "item-list",
|
|
12
18
|
type: "entityList",
|
|
13
19
|
entity: "item",
|
|
14
20
|
columns: ["name"],
|
|
21
|
+
defaultSort: { field: "name", dir: "asc" },
|
|
15
22
|
});
|
|
16
23
|
r.translations({
|
|
17
24
|
keys: {
|
|
@@ -25,12 +32,19 @@ describe("validateBoot — i18n surface keys", () => {
|
|
|
25
32
|
|
|
26
33
|
test("throws when screen title key is missing", () => {
|
|
27
34
|
const feature = defineFeature("demo", (r) => {
|
|
28
|
-
r.entity(
|
|
35
|
+
r.entity(
|
|
36
|
+
"item",
|
|
37
|
+
createEntity({
|
|
38
|
+
table: "Items",
|
|
39
|
+
fields: { name: createTextField({ sortable: true }) },
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
29
42
|
r.screen({
|
|
30
43
|
id: "item-list",
|
|
31
44
|
type: "entityList",
|
|
32
45
|
entity: "item",
|
|
33
46
|
columns: ["name"],
|
|
47
|
+
defaultSort: { field: "name", dir: "asc" },
|
|
34
48
|
});
|
|
35
49
|
r.translations({
|
|
36
50
|
keys: {
|
|
@@ -43,17 +57,24 @@ describe("validateBoot — i18n surface keys", () => {
|
|
|
43
57
|
);
|
|
44
58
|
});
|
|
45
59
|
|
|
46
|
-
test("
|
|
60
|
+
test("throws when feature has screens but no r.translations", () => {
|
|
47
61
|
const feature = defineFeature("legacy", (r) => {
|
|
48
|
-
r.entity(
|
|
62
|
+
r.entity(
|
|
63
|
+
"item",
|
|
64
|
+
createEntity({
|
|
65
|
+
table: "Items",
|
|
66
|
+
fields: { name: createTextField({ sortable: true }) },
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
49
69
|
r.screen({
|
|
50
70
|
id: "item-list",
|
|
51
71
|
type: "entityList",
|
|
52
72
|
entity: "item",
|
|
53
73
|
columns: ["name"],
|
|
74
|
+
defaultSort: { field: "name", dir: "asc" },
|
|
54
75
|
});
|
|
55
76
|
});
|
|
56
|
-
expect(() => validateBoot([feature])).
|
|
77
|
+
expect(() => validateBoot([feature])).toThrow(/required translation key missing/);
|
|
57
78
|
});
|
|
58
79
|
|
|
59
80
|
test("throws when de/en locale is missing", () => {
|
|
@@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import type { SchemaTable } from "../../db/dialect";
|
|
4
4
|
import { table, text } from "../../db/dialect";
|
|
5
|
-
import {
|
|
5
|
+
import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
|
|
6
|
+
import { validateBoot as validateBootRaw } from "../boot-validator";
|
|
6
7
|
import { createSystemConfig, createTenantConfig } from "../config-helpers";
|
|
7
8
|
import {
|
|
8
9
|
createDerivedField,
|
|
@@ -13,6 +14,10 @@ import {
|
|
|
13
14
|
from,
|
|
14
15
|
} from "../index";
|
|
15
16
|
|
|
17
|
+
function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
|
|
18
|
+
validateBootRaw(withBootValidatorFixture(features));
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
describe("boot-validator", () => {
|
|
17
22
|
test("passes for valid features with no issues", () => {
|
|
18
23
|
const features = [
|
|
@@ -2582,7 +2587,7 @@ describe("boot-validator", () => {
|
|
|
2582
2587
|
});
|
|
2583
2588
|
|
|
2584
2589
|
test("number-Field OHNE sortable → Throw (sortable: true ist Pflicht)", () => {
|
|
2585
|
-
expect(() =>
|
|
2590
|
+
expect(() => validateBootRaw([buildFeature("rank", { rank: { type: "number" } })])).toThrow(
|
|
2586
2591
|
/is not sortable/,
|
|
2587
2592
|
);
|
|
2588
2593
|
});
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
|
|
3
|
+
import { validateBoot as validateBootRaw } from "../boot-validator";
|
|
3
4
|
import { defineFeature } from "../define-feature";
|
|
4
5
|
import { createEntity, createTextField } from "../factories";
|
|
5
6
|
import { createRegistry } from "../registry";
|
|
6
7
|
|
|
8
|
+
function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
|
|
9
|
+
validateBootRaw(withBootValidatorFixture(features));
|
|
10
|
+
}
|
|
11
|
+
|
|
7
12
|
function productEntity() {
|
|
8
13
|
return createEntity({
|
|
9
14
|
table: "products",
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
|
|
3
|
+
import { validateBoot as validateBootRaw } from "../boot-validator";
|
|
3
4
|
import { defineFeature } from "../define-feature";
|
|
4
5
|
import { createDerivedField, createEntity, createTextField } from "../factories";
|
|
5
6
|
import { createRegistry } from "../registry";
|
|
6
7
|
import type { ScreenDefinition } from "../types/screen";
|
|
7
8
|
|
|
9
|
+
function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
|
|
10
|
+
validateBootRaw(withBootValidatorFixture(features));
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
function productEntity() {
|
|
9
14
|
return createEntity({
|
|
10
15
|
table: "products",
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { EntityListScreenDefinition, FeatureDefinition } from "../types";
|
|
2
|
+
import { normalizeListColumn } from "../types/screen";
|
|
3
|
+
|
|
4
|
+
/** Operator lists default searchable; low-cardinality audit trails stay opt-out. */
|
|
5
|
+
const SEARCHABLE_FALSE_WHITELIST = new Set(["download-attempt-list"]);
|
|
6
|
+
|
|
7
|
+
function hasFilterableFields(feature: FeatureDefinition, entityName: string): boolean {
|
|
8
|
+
const entities = feature.entities;
|
|
9
|
+
if (!entities) return false;
|
|
10
|
+
const entity = entities[entityName];
|
|
11
|
+
if (!entity) return false;
|
|
12
|
+
return Object.values(entity.fields).some(
|
|
13
|
+
(raw) => (raw as { readonly filterable?: boolean }).filterable === true,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function hasEntityEditDetail(feature: FeatureDefinition, entityName: string): boolean {
|
|
18
|
+
return Object.values(feature.screens).some(
|
|
19
|
+
(s) => s.type === "entityEdit" && s.entity === entityName,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validateOneEntityListScreen(
|
|
24
|
+
feature: FeatureDefinition,
|
|
25
|
+
screen: EntityListScreenDefinition,
|
|
26
|
+
): void {
|
|
27
|
+
const prefix = `[entityList] Feature "${feature.name}" screen "${screen.id}"`;
|
|
28
|
+
|
|
29
|
+
if (screen.searchable === false && !SEARCHABLE_FALSE_WHITELIST.has(screen.id)) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`${prefix}: searchable defaults to true for operator lists — set searchable: true or add "${screen.id}" to the whitelist`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const filtersActive = hasFilterableFields(feature, screen.entity);
|
|
36
|
+
const searchable = screen.searchable !== false;
|
|
37
|
+
if ((searchable || filtersActive) && screen.defaultSort === undefined) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`${prefix}: defaultSort required when searchable or filterable fields are active`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (screen.defaultSort !== undefined) {
|
|
44
|
+
const sortField = screen.defaultSort.field;
|
|
45
|
+
const col = screen.columns.find((c) => normalizeListColumn(c).field === sortField);
|
|
46
|
+
if (col === undefined) {
|
|
47
|
+
throw new Error(`${prefix}: defaultSort.field "${sortField}" is not a listed column`);
|
|
48
|
+
}
|
|
49
|
+
const entityDef = feature.entities?.[screen.entity];
|
|
50
|
+
const fieldDef = entityDef?.fields[sortField];
|
|
51
|
+
const isSortable =
|
|
52
|
+
fieldDef !== undefined && "sortable" in fieldDef && fieldDef.sortable === true;
|
|
53
|
+
if (!isSortable) {
|
|
54
|
+
throw new Error(`${prefix}: defaultSort column "${sortField}" must be sortable`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (hasEntityEditDetail(feature, screen.entity)) {
|
|
59
|
+
const hasNavigate = (screen.rowActions ?? []).some(
|
|
60
|
+
(a) => a.kind === "navigate" && (a.id === "view" || a.id === "edit"),
|
|
61
|
+
);
|
|
62
|
+
if (!hasNavigate) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`${prefix}: detail screen exists — add a navigate rowAction with id "view" or "edit"`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const col of screen.columns) {
|
|
70
|
+
const normalized = normalizeListColumn(col);
|
|
71
|
+
if (normalized.label === undefined) {
|
|
72
|
+
const entity = feature.entities?.[screen.entity];
|
|
73
|
+
const isDerived = entity?.derivedFields?.[normalized.field] !== undefined;
|
|
74
|
+
if (!entity?.fields[normalized.field] && !isDerived) {
|
|
75
|
+
throw new Error(`${prefix}: unknown column field "${normalized.field}"`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function validateEntityListScreens(features: readonly FeatureDefinition[]): void {
|
|
82
|
+
for (const feature of features) {
|
|
83
|
+
for (const screen of Object.values(feature.screens)) {
|
|
84
|
+
if (screen.type !== "entityList") continue;
|
|
85
|
+
validateOneEntityListScreen(feature, screen);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -1,6 +1,22 @@
|
|
|
1
|
-
import { EXT_USER_DATA } from "../extension-names";
|
|
1
|
+
import { EXT_TENANT_DATA, EXT_USER_DATA } from "../extension-names";
|
|
2
2
|
import type { FeatureDefinition } from "../types";
|
|
3
|
-
import type { PiiAnnotations } from "../types/fields";
|
|
3
|
+
import type { EntityDefinition, PiiAnnotations } from "../types/fields";
|
|
4
|
+
|
|
5
|
+
// r.entity(...) is not the only way a feature exposes an entity shape:
|
|
6
|
+
// r.projection(...) can carry an optional `entity` too (raw read-models with
|
|
7
|
+
// no executor, e.g. billing-foundation's subscription table). Both V3 and V4
|
|
8
|
+
// below need every entity a feature declares, not just the r.entity ones, or
|
|
9
|
+
// a tenantOwned/pii field on a projection-only entity is invisible to the
|
|
10
|
+
// guard it was annotated for.
|
|
11
|
+
function entitiesOf(
|
|
12
|
+
feature: FeatureDefinition,
|
|
13
|
+
): ReadonlyArray<readonly [string, EntityDefinition]> {
|
|
14
|
+
const fromEntities = Object.entries(feature.entities ?? {});
|
|
15
|
+
const fromProjections = Object.values(feature.projections)
|
|
16
|
+
.filter((p): p is typeof p & { entity: EntityDefinition } => p.entity !== undefined)
|
|
17
|
+
.map((p) => [p.name, p.entity] as const);
|
|
18
|
+
return [...fromEntities, ...fromProjections];
|
|
19
|
+
}
|
|
4
20
|
|
|
5
21
|
// Providers whose bytes do not survive a process restart. Only "inmemory"
|
|
6
22
|
// today; extend if another ephemeral bundled provider lands.
|
|
@@ -105,7 +121,7 @@ export function validateGdprPiiHookCoverage(features: readonly FeatureDefinition
|
|
|
105
121
|
}
|
|
106
122
|
|
|
107
123
|
for (const feature of features) {
|
|
108
|
-
for (const [entityName, entity] of
|
|
124
|
+
for (const [entityName, entity] of entitiesOf(feature)) {
|
|
109
125
|
if (hookedEntities.has(entityName)) continue;
|
|
110
126
|
const subjectFields = Object.entries(entity.fields)
|
|
111
127
|
.filter(([, field]) => {
|
|
@@ -120,3 +136,36 @@ export function validateGdprPiiHookCoverage(features: readonly FeatureDefinition
|
|
|
120
136
|
}
|
|
121
137
|
}
|
|
122
138
|
}
|
|
139
|
+
|
|
140
|
+
// V4: tenantOwned-entity-without-hook gate. Mirrors validateGdprPiiHookCoverage
|
|
141
|
+
// but for EXT_TENANT_DATA when tenant-lifecycle is mounted.
|
|
142
|
+
export function validateTenantDataHookCoverage(features: readonly FeatureDefinition[]): void {
|
|
143
|
+
const featureNames = new Set(features.map((f) => f.name));
|
|
144
|
+
if (!featureNames.has("tenant-lifecycle")) {
|
|
145
|
+
// skip: this guard only applies to apps that mount tenant-lifecycle
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const hookedEntities = new Set<string>();
|
|
150
|
+
for (const f of features) {
|
|
151
|
+
for (const usage of f.extensionUsages) {
|
|
152
|
+
if (usage.extensionName === EXT_TENANT_DATA) hookedEntities.add(usage.entityName);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const feature of features) {
|
|
157
|
+
for (const [entityName, entity] of entitiesOf(feature)) {
|
|
158
|
+
if (hookedEntities.has(entityName)) continue;
|
|
159
|
+
const tenantSubjectFields = Object.entries(entity.fields)
|
|
160
|
+
.filter(([, field]) => {
|
|
161
|
+
const annot = field as PiiAnnotations;
|
|
162
|
+
return Boolean(annot.tenantOwned);
|
|
163
|
+
})
|
|
164
|
+
.map(([name]) => name);
|
|
165
|
+
if (tenantSubjectFields.length === 0) continue;
|
|
166
|
+
throw new Error(
|
|
167
|
+
`[kumiko:boot] Entity "${entityName}" (feature "${feature.name}") has tenant-subject fields (${tenantSubjectFields.join(", ")}) but no feature registers an EXT_TENANT_DATA destroy hook for it — tenant destroy never erases this data. Register r.useExtension(EXT_TENANT_DATA, "${entityName}", { destroy }) or a documented no-op if crypto-shredding covers it.`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -1,20 +1,68 @@
|
|
|
1
1
|
import {
|
|
2
2
|
buildEffectiveTranslationKeys,
|
|
3
|
+
featureHasI18nSurface,
|
|
3
4
|
findTranslationLocaleGaps,
|
|
4
5
|
requiredKeysFromFeature,
|
|
6
|
+
requiredKeysFromNav,
|
|
7
|
+
requiredKeysFromScreen,
|
|
8
|
+
requiredKeysFromWorkspace,
|
|
5
9
|
} from "../../i18n/required-surface-keys";
|
|
10
|
+
import { buildConfigFeatureSchema, SETTINGS_HUB_FEATURE } from "../build-config-feature-schema";
|
|
11
|
+
import { createRegistry } from "../registry";
|
|
6
12
|
import type { FeatureDefinition } from "../types";
|
|
13
|
+
import type { Registry } from "../types/feature";
|
|
14
|
+
|
|
15
|
+
function requiredKeysFromGeneratedConfigHub(registry: Registry): readonly string[] {
|
|
16
|
+
const schema = buildConfigFeatureSchema(registry);
|
|
17
|
+
if (schema.navs.length === 0) return [];
|
|
18
|
+
const out = new Set<string>();
|
|
19
|
+
|
|
20
|
+
for (const screen of schema.screens) {
|
|
21
|
+
for (const key of requiredKeysFromScreen(SETTINGS_HUB_FEATURE, screen)) out.add(key);
|
|
22
|
+
}
|
|
23
|
+
for (const nav of schema.navs) {
|
|
24
|
+
for (const key of requiredKeysFromNav(nav)) out.add(key);
|
|
25
|
+
}
|
|
26
|
+
if (schema.workspace) {
|
|
27
|
+
for (const key of requiredKeysFromWorkspace(schema.workspace.definition)) out.add(key);
|
|
28
|
+
}
|
|
29
|
+
out.add("config.settings.title");
|
|
30
|
+
for (const scope of ["system", "tenant", "user"] as const) {
|
|
31
|
+
out.add(`config.settings.${scope}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return [...out];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isFrameworkOwnedI18nKey(key: string): boolean {
|
|
38
|
+
return key.startsWith("kumiko.");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hasDefinedTranslation(defined: Set<string>, key: string): boolean {
|
|
42
|
+
if (defined.has(key)) return true;
|
|
43
|
+
const colon = key.indexOf(":");
|
|
44
|
+
if (colon > 0) {
|
|
45
|
+
const feature = key.slice(0, colon);
|
|
46
|
+
const local = key.slice(colon + 1);
|
|
47
|
+
if (defined.has(`${feature}:${local}`)) return true;
|
|
48
|
+
}
|
|
49
|
+
if (key.includes(".")) {
|
|
50
|
+
const feature = key.split(".")[0];
|
|
51
|
+
if (feature && defined.has(`${feature}:${key}`)) return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
7
55
|
|
|
8
56
|
export function validateI18nSurfaceKeys(features: readonly FeatureDefinition[]): void {
|
|
9
57
|
const defined = buildEffectiveTranslationKeys(features);
|
|
58
|
+
const registry = createRegistry(features);
|
|
10
59
|
|
|
11
60
|
for (const feature of features) {
|
|
12
|
-
|
|
13
|
-
// keys, surface + locale checks apply (apps, new bundled work).
|
|
14
|
-
if (Object.keys(feature.translations ?? {}).length === 0) continue;
|
|
61
|
+
if (!featureHasI18nSurface(feature)) continue;
|
|
15
62
|
|
|
16
63
|
for (const key of requiredKeysFromFeature(feature)) {
|
|
17
|
-
if (
|
|
64
|
+
if (isFrameworkOwnedI18nKey(key)) continue;
|
|
65
|
+
if (!hasDefinedTranslation(defined, key)) {
|
|
18
66
|
throw new Error(
|
|
19
67
|
`[i18n] Feature "${feature.name}": required translation key missing: "${key}"`,
|
|
20
68
|
);
|
|
@@ -22,6 +70,12 @@ export function validateI18nSurfaceKeys(features: readonly FeatureDefinition[]):
|
|
|
22
70
|
}
|
|
23
71
|
}
|
|
24
72
|
|
|
73
|
+
for (const key of requiredKeysFromGeneratedConfigHub(registry)) {
|
|
74
|
+
if (!hasDefinedTranslation(defined, key)) {
|
|
75
|
+
throw new Error(`[i18n] Settings-Hub: required translation key missing: "${key}"`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
25
79
|
for (const gap of findTranslationLocaleGaps(features)) {
|
|
26
80
|
throw new Error(
|
|
27
81
|
`[i18n] Feature "${gap.featureName}": key "${gap.key}" missing locale(s): ${gap.missingLocales.join(", ")}`,
|
|
@@ -27,10 +27,12 @@ import {
|
|
|
27
27
|
validateReferenceFields,
|
|
28
28
|
validateTransitions,
|
|
29
29
|
} from "./entity-handler";
|
|
30
|
+
import { validateEntityListScreens } from "./entity-list-screens";
|
|
30
31
|
import {
|
|
31
32
|
validateGdprHookCompleteness,
|
|
32
33
|
validateGdprPiiHookCoverage,
|
|
33
34
|
validateGdprStoragePersistence,
|
|
35
|
+
validateTenantDataHookCoverage,
|
|
34
36
|
} from "./gdpr-storage";
|
|
35
37
|
import { validateI18nSurfaceKeys } from "./i18n-keys";
|
|
36
38
|
import { validateOwnershipRules } from "./ownership";
|
|
@@ -180,10 +182,12 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
|
|
|
180
182
|
validateNavCycles(allNavQns);
|
|
181
183
|
validateDefaultWorkspaceUniqueness(allWorkspaceQns);
|
|
182
184
|
validateI18nSurfaceKeys(features);
|
|
185
|
+
validateEntityListScreens(features);
|
|
183
186
|
validateExtensionPreSaveWiring(features);
|
|
184
187
|
validateGdprStoragePersistence(features);
|
|
185
188
|
validateGdprHookCompleteness(features);
|
|
186
189
|
validateGdprPiiHookCoverage(features);
|
|
190
|
+
validateTenantDataHookCoverage(features);
|
|
187
191
|
|
|
188
192
|
if (hasEncryptedFields) {
|
|
189
193
|
// Availability check, not env-presence: eagerly building the keyring
|
|
@@ -123,6 +123,7 @@ export function buildConfigFeatureSchema(registry: Registry): ConfigFeatureSchem
|
|
|
123
123
|
label: `${feature}.settings`,
|
|
124
124
|
parent: audienceNavShortId(scope),
|
|
125
125
|
screen: shortId,
|
|
126
|
+
icon: ordered[0]?.def.mask?.icon ?? "settings",
|
|
126
127
|
order: minMaskOrder(ordered),
|
|
127
128
|
access,
|
|
128
129
|
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Hook-Signatur-Types für EXT_TENANT_DATA (DSGVO tenant-scoped destroy).
|
|
2
|
+
//
|
|
3
|
+
// Mirror of engine/extensions/user-data.ts at tenant granularity.
|
|
4
|
+
// tenant-lifecycle orchestrates destroy via registry.getExtensionUsages(EXT_TENANT_DATA).
|
|
5
|
+
|
|
6
|
+
import type { DbRunner } from "../../db/connection";
|
|
7
|
+
import type { Registry, TenantId } from "../types";
|
|
8
|
+
|
|
9
|
+
export interface TenantDataHookCtx {
|
|
10
|
+
readonly db: DbRunner;
|
|
11
|
+
readonly registry: Registry;
|
|
12
|
+
readonly tenantId: TenantId;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type TenantDataDestroyHook = (ctx: TenantDataHookCtx) => Promise<void>;
|
|
16
|
+
|
|
17
|
+
export interface TenantDataExtensionHooks {
|
|
18
|
+
readonly destroy: TenantDataDestroyHook;
|
|
19
|
+
}
|
package/src/engine/index.ts
CHANGED
|
@@ -81,6 +81,11 @@ export {
|
|
|
81
81
|
EXT_USER_DATA_ORDER,
|
|
82
82
|
FILE_PROVIDER_CONFIG_KEY,
|
|
83
83
|
} from "./extension-names";
|
|
84
|
+
export type {
|
|
85
|
+
TenantDataDestroyHook,
|
|
86
|
+
TenantDataExtensionHooks,
|
|
87
|
+
TenantDataHookCtx,
|
|
88
|
+
} from "./extensions/tenant-data";
|
|
84
89
|
export type {
|
|
85
90
|
TenantUserModel,
|
|
86
91
|
UserDataDeleteHook,
|
|
@@ -3,6 +3,7 @@ import type { TableColumns } from "../../db/dialect";
|
|
|
3
3
|
import type { StoredEvent } from "../../event-store/event-store";
|
|
4
4
|
import type { MultiStreamApplyContext } from "../../pipeline/multi-stream-apply-context";
|
|
5
5
|
import type { RunIn } from "./config";
|
|
6
|
+
import type { EntityDefinition } from "./fields";
|
|
6
7
|
|
|
7
8
|
// Drizzle pgTable shape — projections hand their table through to apply() so
|
|
8
9
|
// user code writes upserts/updates directly instead of going through a
|
|
@@ -59,6 +60,10 @@ export type ProjectionDefinition = {
|
|
|
59
60
|
// Drizzle-table the projection materializes into. User owns the schema —
|
|
60
61
|
// framework just guarantees the TX and event delivery.
|
|
61
62
|
readonly table: ProjectionTable;
|
|
63
|
+
// Optional: the EntityDefinition the table was built from, for projections
|
|
64
|
+
// without an r.entity registration — lets boot-time GDPR guards see
|
|
65
|
+
// pii/tenantOwned fields that feature.entities (r.entity-only) would miss.
|
|
66
|
+
readonly entity?: EntityDefinition;
|
|
62
67
|
// Keyed by fully-qualified event type ("<aggregate>.<verb>", e.g. "unit.created").
|
|
63
68
|
// Missing keys are silently skipped — a projection declares only the events it
|
|
64
69
|
// cares about.
|
|
@@ -472,6 +472,8 @@ export type CustomScreenDefinition = {
|
|
|
472
472
|
readonly type: "custom";
|
|
473
473
|
readonly renderer: PlatformComponent;
|
|
474
474
|
readonly routes?: readonly CustomScreenRoute[];
|
|
475
|
+
/** Parent list screen for breadcrumb when this detail is not in nav. */
|
|
476
|
+
readonly listScreenId?: string;
|
|
475
477
|
readonly access?: AccessRule;
|
|
476
478
|
};
|
|
477
479
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { booleanFacetOptionKeys, selectFacetOptionKey } from "../required-surface-keys";
|
|
3
|
+
|
|
4
|
+
describe("required-surface-keys helpers", () => {
|
|
5
|
+
test("booleanFacetOptionKeys emits true/false option keys", () => {
|
|
6
|
+
expect(booleanFacetOptionKeys("tenant", "tenant", "isEnabled")).toEqual([
|
|
7
|
+
"tenant:entity:tenant:field:isEnabled:option:true",
|
|
8
|
+
"tenant:entity:tenant:field:isEnabled:option:false",
|
|
9
|
+
]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("selectFacetOptionKey encodes option value", () => {
|
|
13
|
+
expect(selectFacetOptionKey("user", "user", "status", "active")).toBe(
|
|
14
|
+
"user:entity:user:field:status:option:active",
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -23,6 +23,24 @@ export function fieldLabelKey(featureName: string, entityName: string, fieldName
|
|
|
23
23
|
return `${featureName}:entity:${entityName}:field:${fieldName}`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export function booleanFacetOptionKeys(
|
|
27
|
+
featureName: string,
|
|
28
|
+
entityName: string,
|
|
29
|
+
fieldName: string,
|
|
30
|
+
): readonly string[] {
|
|
31
|
+
const base = `${featureName}:entity:${entityName}:field:${fieldName}:option`;
|
|
32
|
+
return [`${base}:true`, `${base}:false`];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function selectFacetOptionKey(
|
|
36
|
+
featureName: string,
|
|
37
|
+
entityName: string,
|
|
38
|
+
fieldName: string,
|
|
39
|
+
value: string,
|
|
40
|
+
): string {
|
|
41
|
+
return `${featureName}:entity:${entityName}:field:${fieldName}:option:${value}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
26
44
|
export function screenTitleKey(screenId: string): string {
|
|
27
45
|
return `screen:${screenId}.title`;
|
|
28
46
|
}
|
|
@@ -167,6 +185,41 @@ export function requiredKeysFromWorkspace(ws: WorkspaceDefinition): readonly str
|
|
|
167
185
|
return [...out];
|
|
168
186
|
}
|
|
169
187
|
|
|
188
|
+
function collectEntityListFilterKeys(feature: FeatureDefinition, out: Set<string>): void {
|
|
189
|
+
for (const screen of Object.values(feature.screens)) {
|
|
190
|
+
if (screen.type !== "entityList") continue;
|
|
191
|
+
const entity = feature.entities?.[screen.entity];
|
|
192
|
+
if (!entity) continue;
|
|
193
|
+
for (const [fieldName, rawDef] of Object.entries(entity.fields)) {
|
|
194
|
+
const def = rawDef as {
|
|
195
|
+
readonly filterable?: boolean;
|
|
196
|
+
readonly type?: string;
|
|
197
|
+
readonly options?: readonly string[];
|
|
198
|
+
};
|
|
199
|
+
if (def.filterable !== true) continue;
|
|
200
|
+
if (def.type === "boolean") {
|
|
201
|
+
for (const key of booleanFacetOptionKeys(feature.name, screen.entity, fieldName)) {
|
|
202
|
+
out.add(key);
|
|
203
|
+
}
|
|
204
|
+
} else if (def.type === "select" && Array.isArray(def.options)) {
|
|
205
|
+
for (const value of def.options) {
|
|
206
|
+
out.add(selectFacetOptionKey(feature.name, screen.entity, fieldName, value));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function featureHasI18nSurface(feature: FeatureDefinition): boolean {
|
|
214
|
+
if (Object.keys(feature.screens).length > 0) return true;
|
|
215
|
+
if (Object.keys(feature.navs).length > 0) return true;
|
|
216
|
+
if (Object.keys(feature.workspaces).length > 0) return true;
|
|
217
|
+
for (const def of Object.values(feature.configKeys)) {
|
|
218
|
+
if (def.mask !== undefined) return true;
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
170
223
|
export function requiredKeysFromFeature(feature: FeatureDefinition): readonly string[] {
|
|
171
224
|
const out = new Set<string>();
|
|
172
225
|
|
|
@@ -182,6 +235,7 @@ export function requiredKeysFromFeature(feature: FeatureDefinition): readonly st
|
|
|
182
235
|
for (const def of Object.values(feature.configKeys)) {
|
|
183
236
|
pushKey(out, def.mask?.title);
|
|
184
237
|
}
|
|
238
|
+
collectEntityListFilterKeys(feature, out);
|
|
185
239
|
|
|
186
240
|
return [...out];
|
|
187
241
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EntityDefinition,
|
|
3
|
+
EntityListScreenDefinition,
|
|
4
|
+
FeatureDefinition,
|
|
5
|
+
TranslationEntry,
|
|
6
|
+
} from "../engine/types";
|
|
7
|
+
import { normalizeListColumn } from "../engine/types/screen";
|
|
8
|
+
import { featureHasI18nSurface, requiredKeysFromFeature } from "../i18n/required-surface-keys";
|
|
9
|
+
|
|
10
|
+
const SEARCHABLE_FALSE_WHITELIST = new Set(["download-attempt-list"]);
|
|
11
|
+
|
|
12
|
+
function ensureEntityListSortable(feature: FeatureDefinition): FeatureDefinition {
|
|
13
|
+
if (!feature.entities) return feature;
|
|
14
|
+
const entities: Record<string, EntityDefinition> = { ...feature.entities };
|
|
15
|
+
|
|
16
|
+
for (const screen of Object.values(feature.screens)) {
|
|
17
|
+
if (screen.type !== "entityList") continue;
|
|
18
|
+
const entity = entities[screen.entity];
|
|
19
|
+
if (!entity) continue;
|
|
20
|
+
|
|
21
|
+
const hasSortable = Object.values(entity.fields).some(
|
|
22
|
+
(field) => "sortable" in field && field.sortable === true,
|
|
23
|
+
);
|
|
24
|
+
if (hasSortable) continue;
|
|
25
|
+
|
|
26
|
+
const firstCol = screen.columns[0];
|
|
27
|
+
if (firstCol === undefined) continue;
|
|
28
|
+
const fieldName = normalizeListColumn(firstCol).field;
|
|
29
|
+
const fieldDef = entity.fields[fieldName];
|
|
30
|
+
if (fieldDef === undefined) continue;
|
|
31
|
+
|
|
32
|
+
if (fieldDef.type !== "text" && fieldDef.type !== "number") continue;
|
|
33
|
+
|
|
34
|
+
entities[screen.entity] = {
|
|
35
|
+
...entity,
|
|
36
|
+
fields: {
|
|
37
|
+
...entity.fields,
|
|
38
|
+
[fieldName]: { ...fieldDef, sortable: true },
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { ...feature, entities };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function ensureEntityListRowNavigation(
|
|
47
|
+
feature: FeatureDefinition,
|
|
48
|
+
screen: EntityListScreenDefinition,
|
|
49
|
+
): EntityListScreenDefinition {
|
|
50
|
+
const hasEdit = Object.values(feature.screens).some(
|
|
51
|
+
(s) => s.type === "entityEdit" && s.entity === screen.entity,
|
|
52
|
+
);
|
|
53
|
+
if (!hasEdit) return screen;
|
|
54
|
+
const hasNavigate = (screen.rowActions ?? []).some(
|
|
55
|
+
(a) => a.kind === "navigate" && (a.id === "view" || a.id === "edit"),
|
|
56
|
+
);
|
|
57
|
+
if (hasNavigate) return screen;
|
|
58
|
+
const editScreen = Object.values(feature.screens).find(
|
|
59
|
+
(s) => s.type === "entityEdit" && s.entity === screen.entity,
|
|
60
|
+
);
|
|
61
|
+
if (editScreen === undefined) return screen;
|
|
62
|
+
return {
|
|
63
|
+
...screen,
|
|
64
|
+
rowActions: [
|
|
65
|
+
...(screen.rowActions ?? []),
|
|
66
|
+
{
|
|
67
|
+
kind: "navigate",
|
|
68
|
+
id: "edit",
|
|
69
|
+
label: "stub:edit",
|
|
70
|
+
screen: editScreen.id,
|
|
71
|
+
params: { pick: ["id"] },
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function defaultSortForEntityList(
|
|
78
|
+
feature: FeatureDefinition,
|
|
79
|
+
screen: EntityListScreenDefinition,
|
|
80
|
+
): EntityListScreenDefinition {
|
|
81
|
+
if (screen.defaultSort !== undefined || screen.searchable === false) return screen;
|
|
82
|
+
if (SEARCHABLE_FALSE_WHITELIST.has(screen.id)) return screen;
|
|
83
|
+
|
|
84
|
+
const entity = feature.entities?.[screen.entity];
|
|
85
|
+
const sortableColumn = screen.columns.map(normalizeListColumn).find((col) => {
|
|
86
|
+
const fieldDef = entity?.fields[col.field];
|
|
87
|
+
return fieldDef !== undefined && "sortable" in fieldDef && fieldDef.sortable === true;
|
|
88
|
+
});
|
|
89
|
+
if (sortableColumn === undefined) return screen;
|
|
90
|
+
|
|
91
|
+
return { ...screen, defaultSort: { field: sortableColumn.field, dir: "asc" as const } };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function stubTranslations(feature: FeatureDefinition): FeatureDefinition {
|
|
95
|
+
if (!featureHasI18nSurface(feature)) return feature;
|
|
96
|
+
const keys: Record<string, TranslationEntry> = {
|
|
97
|
+
...Object.fromEntries(
|
|
98
|
+
Object.entries(feature.translations ?? {}).map(([key, value]) => [key, { ...value }]),
|
|
99
|
+
),
|
|
100
|
+
};
|
|
101
|
+
for (const key of requiredKeysFromFeature(feature)) {
|
|
102
|
+
if (keys[key] === undefined) keys[key] = { de: "stub", en: "stub" };
|
|
103
|
+
}
|
|
104
|
+
return { ...feature, translations: keys };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Test-fixture helper: satisfy entityList + i18n boot rules without bloating every makeFeature. */
|
|
108
|
+
export function withBootValidatorFixture(
|
|
109
|
+
features: readonly FeatureDefinition[],
|
|
110
|
+
): FeatureDefinition[] {
|
|
111
|
+
return features.map((feature) => {
|
|
112
|
+
const sortable = ensureEntityListSortable(feature);
|
|
113
|
+
const screens = Object.fromEntries(
|
|
114
|
+
Object.entries(sortable.screens).map(([id, screen]) => {
|
|
115
|
+
if (screen.type !== "entityList") return [id, screen];
|
|
116
|
+
const withDefaults = defaultSortForEntityList(sortable, screen);
|
|
117
|
+
return [id, ensureEntityListRowNavigation(sortable, withDefaults)];
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
return stubTranslations({ ...sortable, screens });
|
|
121
|
+
});
|
|
122
|
+
}
|
package/src/testing/index.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
export { rolesOf } from "./access-assertions";
|
|
7
7
|
export { expectError, expectSuccess } from "./assertions";
|
|
8
|
+
export { withBootValidatorFixture } from "./boot-validator-fixture";
|
|
8
9
|
export { type ClearableTable, clearTables, resetTestTables } from "./db-cleanup";
|
|
9
10
|
export {
|
|
10
11
|
type E2EGeneratorOptions,
|