@cosmicdrift/kumiko-framework 0.170.0 → 0.171.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/__tests__/schema-cli.integration.test.ts +19 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +36 -0
- package/src/engine/__tests__/boot-validator.test.ts +200 -0
- package/src/engine/__tests__/schema-builder.test.ts +117 -0
- package/src/engine/boot-validator/__tests__/access-roles.test.ts +84 -0
- package/src/engine/boot-validator/access-roles.ts +43 -0
- package/src/engine/boot-validator/entity-handler.ts +21 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/boot-validator/pii-retention.ts +10 -1
- package/src/engine/boot-validator/screens.ts +42 -0
- package/src/engine/index.ts +6 -1
- package/src/engine/schema-builder.ts +16 -2
- package/src/schema-cli.ts +16 -3
- package/src/testing/boot-validator-fixture.ts +4 -1
- package/src/ui-types/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.171.1",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.171.1",
|
|
186
186
|
"bullmq": "^5.76.7",
|
|
187
187
|
"bun-types": "^1.3.13",
|
|
188
188
|
"hono": "^4.12.27",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"zod": "^4.4.3"
|
|
199
199
|
},
|
|
200
200
|
"devDependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.171.1",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
|
@@ -200,6 +200,25 @@ CREATE TABLE IF NOT EXISTS "read_widgets_v2" ("id" uuid PRIMARY KEY);
|
|
|
200
200
|
expect(cap.err.join("\n")).toContain("read_widgets");
|
|
201
201
|
});
|
|
202
202
|
|
|
203
|
+
test("migration creates a table with no snapshot entry → unexpected-table hint points at r.storeTable, not hand-fix", async () => {
|
|
204
|
+
writeSchemaFile(appCwd, "read_widgets");
|
|
205
|
+
await runSchemaCli(["generate", "init"], appCwd, captureOut().out);
|
|
206
|
+
writeFileSync(
|
|
207
|
+
join(appCwd, "kumiko/migrations/0002_raw.sql"),
|
|
208
|
+
`-- hand-written table outside the entity system, never registered via r.storeTable
|
|
209
|
+
CREATE TABLE IF NOT EXISTS "marketing_waitlist" ("id" uuid PRIMARY KEY);
|
|
210
|
+
`,
|
|
211
|
+
);
|
|
212
|
+
const cap = captureOut();
|
|
213
|
+
const code = await runSchemaCli(["validate"], appCwd, cap.out);
|
|
214
|
+
expect(code).toBe(1);
|
|
215
|
+
const err = cap.err.join("\n");
|
|
216
|
+
expect(err).toContain("marketing_waitlist");
|
|
217
|
+
expect(err).toContain("Fix (unexpected-table)");
|
|
218
|
+
expect(err).toContain("r.storeTable");
|
|
219
|
+
expect(err).not.toContain("Fix (missing-table/column-drift)");
|
|
220
|
+
});
|
|
221
|
+
|
|
203
222
|
test("no FEATURES export → validateBoot skipped (drift still checked)", async () => {
|
|
204
223
|
writeSchemaFile(appCwd, "read_widgets");
|
|
205
224
|
await runSchemaCli(["generate", "init"], appCwd, captureOut().out);
|
|
@@ -271,6 +271,42 @@ describe("validateBoot — PII annotations", () => {
|
|
|
271
271
|
expect(matchingWarn).toBeDefined();
|
|
272
272
|
});
|
|
273
273
|
|
|
274
|
+
test("user-reference-name heuristic warns when authorId field has no subjectRef annotation", () => {
|
|
275
|
+
const feature = defineFeature("test", (r) => {
|
|
276
|
+
r.entity(
|
|
277
|
+
"thing",
|
|
278
|
+
createEntity({
|
|
279
|
+
fields: {
|
|
280
|
+
authorId: createTextField(),
|
|
281
|
+
},
|
|
282
|
+
}),
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
validateBoot([feature]);
|
|
286
|
+
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
287
|
+
String(args[0]).includes("user-reference-typical name"),
|
|
288
|
+
);
|
|
289
|
+
expect(matchingWarn).toBeDefined();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("subjectRef: true on authorId field silences user-reference-name heuristic warning", () => {
|
|
293
|
+
const feature = defineFeature("test", (r) => {
|
|
294
|
+
r.entity(
|
|
295
|
+
"thing",
|
|
296
|
+
createEntity({
|
|
297
|
+
fields: {
|
|
298
|
+
authorId: createTextField({ subjectRef: true }),
|
|
299
|
+
},
|
|
300
|
+
}),
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
validateBoot([feature]);
|
|
304
|
+
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
305
|
+
String(args[0]).includes("user-reference-typical name"),
|
|
306
|
+
);
|
|
307
|
+
expect(matchingWarn).toBeUndefined();
|
|
308
|
+
});
|
|
309
|
+
|
|
274
310
|
test("allowPlaintext marker silences PII-name heuristic warning", () => {
|
|
275
311
|
const feature = defineFeature("test", (r) => {
|
|
276
312
|
r.entity(
|
|
@@ -2820,4 +2820,204 @@ describe("boot-validator — config key backing × scope", () => {
|
|
|
2820
2820
|
});
|
|
2821
2821
|
expect(() => validateBoot([feature])).not.toThrow();
|
|
2822
2822
|
});
|
|
2823
|
+
|
|
2824
|
+
test("navigate with params targeting a dashboard screen → Throw", () => {
|
|
2825
|
+
const feature = defineFeature("shop", (r) => {
|
|
2826
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
2827
|
+
r.screen({
|
|
2828
|
+
id: "product-list",
|
|
2829
|
+
type: "entityList",
|
|
2830
|
+
entity: "product",
|
|
2831
|
+
columns: ["name"],
|
|
2832
|
+
rowActions: [
|
|
2833
|
+
{
|
|
2834
|
+
kind: "navigate",
|
|
2835
|
+
id: "view",
|
|
2836
|
+
label: "actions.view",
|
|
2837
|
+
screen: "product-dashboard",
|
|
2838
|
+
params: { pick: ["name"] },
|
|
2839
|
+
},
|
|
2840
|
+
],
|
|
2841
|
+
});
|
|
2842
|
+
r.queryHandler("count", z.object({}), async () => ({ total: 0 }), {
|
|
2843
|
+
access: { openToAll: true },
|
|
2844
|
+
});
|
|
2845
|
+
r.screen({
|
|
2846
|
+
id: "product-dashboard",
|
|
2847
|
+
type: "dashboard",
|
|
2848
|
+
panels: [
|
|
2849
|
+
{
|
|
2850
|
+
kind: "stat",
|
|
2851
|
+
id: "count",
|
|
2852
|
+
label: "Products",
|
|
2853
|
+
query: "shop:query:count",
|
|
2854
|
+
valueField: "total",
|
|
2855
|
+
},
|
|
2856
|
+
],
|
|
2857
|
+
});
|
|
2858
|
+
});
|
|
2859
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
2860
|
+
/sets params on navigate-target "product-dashboard".*screen type "dashboard".*only actionForm and entityEdit-create/,
|
|
2861
|
+
);
|
|
2862
|
+
});
|
|
2863
|
+
|
|
2864
|
+
test("navigate with params targeting a custom screen → no throw (author owns the component, may read searchParams itself)", () => {
|
|
2865
|
+
const feature = defineFeature("shop", (r) => {
|
|
2866
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
2867
|
+
r.screen({
|
|
2868
|
+
id: "product-list",
|
|
2869
|
+
type: "entityList",
|
|
2870
|
+
entity: "product",
|
|
2871
|
+
columns: ["name"],
|
|
2872
|
+
rowActions: [
|
|
2873
|
+
{
|
|
2874
|
+
kind: "navigate",
|
|
2875
|
+
id: "view",
|
|
2876
|
+
label: "actions.view",
|
|
2877
|
+
screen: "product-dashboard",
|
|
2878
|
+
params: { pick: ["name"] },
|
|
2879
|
+
},
|
|
2880
|
+
],
|
|
2881
|
+
});
|
|
2882
|
+
r.screen({
|
|
2883
|
+
id: "product-dashboard",
|
|
2884
|
+
type: "custom",
|
|
2885
|
+
renderer: { react: "stub" },
|
|
2886
|
+
});
|
|
2887
|
+
});
|
|
2888
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
2889
|
+
});
|
|
2890
|
+
|
|
2891
|
+
test("navigate with params targeting a same-entity entityEdit screen → Throw (resolves to update mode)", () => {
|
|
2892
|
+
// No explicit entityId + same entity as the source list → the renderer's
|
|
2893
|
+
// runNavigate() auto-fills row["id"], landing in EntityEditUpdateBody,
|
|
2894
|
+
// which never reads searchParams. Params would silently no-op.
|
|
2895
|
+
const feature = defineFeature("shop", (r) => {
|
|
2896
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
2897
|
+
r.screen({
|
|
2898
|
+
id: "product-list",
|
|
2899
|
+
type: "entityList",
|
|
2900
|
+
entity: "product",
|
|
2901
|
+
columns: ["name"],
|
|
2902
|
+
rowActions: [
|
|
2903
|
+
{
|
|
2904
|
+
kind: "navigate",
|
|
2905
|
+
id: "open",
|
|
2906
|
+
label: "actions.open",
|
|
2907
|
+
screen: "product-edit",
|
|
2908
|
+
params: { pick: ["name"] },
|
|
2909
|
+
},
|
|
2910
|
+
],
|
|
2911
|
+
});
|
|
2912
|
+
r.screen({
|
|
2913
|
+
id: "product-edit",
|
|
2914
|
+
type: "entityEdit",
|
|
2915
|
+
entity: "product",
|
|
2916
|
+
layout: { sections: [{ columns: 1, fields: ["name"] }] },
|
|
2917
|
+
});
|
|
2918
|
+
});
|
|
2919
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
2920
|
+
/resolves to UPDATE mode.*same entity "product" auto-fills row\["id"\]/,
|
|
2921
|
+
);
|
|
2922
|
+
});
|
|
2923
|
+
|
|
2924
|
+
test("navigate with params + explicit entityId targeting an entityEdit screen → Throw (forced update mode)", () => {
|
|
2925
|
+
const feature = defineFeature("housing", (r) => {
|
|
2926
|
+
r.entity("unit", createEntity({ fields: { name: createTextField() } }));
|
|
2927
|
+
r.entity("contract", createEntity({ fields: { unitId: createTextField() } }));
|
|
2928
|
+
r.screen({
|
|
2929
|
+
id: "unit-list",
|
|
2930
|
+
type: "entityList",
|
|
2931
|
+
entity: "unit",
|
|
2932
|
+
columns: ["name"],
|
|
2933
|
+
rowActions: [
|
|
2934
|
+
{
|
|
2935
|
+
kind: "navigate",
|
|
2936
|
+
id: "edit-contract",
|
|
2937
|
+
label: "actions.edit-contract",
|
|
2938
|
+
screen: "contract-edit",
|
|
2939
|
+
entityId: "name",
|
|
2940
|
+
params: { pick: ["name"] },
|
|
2941
|
+
},
|
|
2942
|
+
],
|
|
2943
|
+
});
|
|
2944
|
+
r.screen({
|
|
2945
|
+
id: "contract-edit",
|
|
2946
|
+
type: "entityEdit",
|
|
2947
|
+
entity: "contract",
|
|
2948
|
+
layout: { sections: [{ columns: 1, fields: ["unitId"] }] },
|
|
2949
|
+
});
|
|
2950
|
+
});
|
|
2951
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
2952
|
+
/resolves to UPDATE mode \(explicit entityId "name"\)/,
|
|
2953
|
+
);
|
|
2954
|
+
});
|
|
2955
|
+
|
|
2956
|
+
test("navigate with params targeting a cross-entity entityEdit screen (no explicit entityId) → no throw", () => {
|
|
2957
|
+
// The issue's actual use case: a unit row navigates to "create contract"
|
|
2958
|
+
// pre-filled with unitId. Different entity + no explicit entityId → the
|
|
2959
|
+
// renderer's same-entity row["id"] fallback does NOT fire, so this stays
|
|
2960
|
+
// in create mode and EntityEditCreateBody reads the params.
|
|
2961
|
+
const feature = defineFeature("housing", (r) => {
|
|
2962
|
+
r.entity("unit", createEntity({ fields: { name: createTextField() } }));
|
|
2963
|
+
r.entity("contract", createEntity({ fields: { unitId: createTextField() } }));
|
|
2964
|
+
r.screen({
|
|
2965
|
+
id: "unit-list",
|
|
2966
|
+
type: "entityList",
|
|
2967
|
+
entity: "unit",
|
|
2968
|
+
columns: ["name"],
|
|
2969
|
+
rowActions: [
|
|
2970
|
+
{
|
|
2971
|
+
kind: "navigate",
|
|
2972
|
+
id: "create-contract",
|
|
2973
|
+
label: "actions.create-contract",
|
|
2974
|
+
screen: "contract-edit",
|
|
2975
|
+
params: { map: { unitId: "name" } },
|
|
2976
|
+
},
|
|
2977
|
+
],
|
|
2978
|
+
});
|
|
2979
|
+
r.screen({
|
|
2980
|
+
id: "contract-edit",
|
|
2981
|
+
type: "entityEdit",
|
|
2982
|
+
entity: "contract",
|
|
2983
|
+
layout: { sections: [{ columns: 1, fields: ["unitId"] }] },
|
|
2984
|
+
});
|
|
2985
|
+
});
|
|
2986
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
2987
|
+
});
|
|
2988
|
+
|
|
2989
|
+
test("navigate with params targeting an actionForm screen → no throw", () => {
|
|
2990
|
+
const feature = defineFeature("shop", (r) => {
|
|
2991
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
2992
|
+
r.writeHandler(
|
|
2993
|
+
"create",
|
|
2994
|
+
z.object({ name: z.string() }),
|
|
2995
|
+
async () => ({ isSuccess: true as const, data: { id: "1" } }),
|
|
2996
|
+
{ access: { openToAll: true } },
|
|
2997
|
+
);
|
|
2998
|
+
r.screen({
|
|
2999
|
+
id: "product-list",
|
|
3000
|
+
type: "entityList",
|
|
3001
|
+
entity: "product",
|
|
3002
|
+
columns: ["name"],
|
|
3003
|
+
rowActions: [
|
|
3004
|
+
{
|
|
3005
|
+
kind: "navigate",
|
|
3006
|
+
id: "open-form",
|
|
3007
|
+
label: "actions.form",
|
|
3008
|
+
screen: "product-form",
|
|
3009
|
+
params: { pick: ["name"] },
|
|
3010
|
+
},
|
|
3011
|
+
],
|
|
3012
|
+
});
|
|
3013
|
+
r.screen({
|
|
3014
|
+
id: "product-form",
|
|
3015
|
+
type: "actionForm",
|
|
3016
|
+
handler: "shop:write:create",
|
|
3017
|
+
fields: { name: createTextField() },
|
|
3018
|
+
layout: { sections: [{ columns: 1, fields: ["name"] }] },
|
|
3019
|
+
});
|
|
3020
|
+
});
|
|
3021
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
3022
|
+
});
|
|
2823
3023
|
});
|
|
@@ -384,6 +384,71 @@ describe("buildInsertSchema", () => {
|
|
|
384
384
|
schema.safeParse({ pickup: { at: "2026-04-03T10:00:00", tz: "Mars/Phobos" } }).success,
|
|
385
385
|
).toBe(false);
|
|
386
386
|
});
|
|
387
|
+
|
|
388
|
+
// #1674: an untouched HTML <select> submits "" for its placeholder option.
|
|
389
|
+
// An optional select without a default must treat "" as "not set" (null),
|
|
390
|
+
// not reject it as an invalid enum value.
|
|
391
|
+
test("optional select without default accepts empty string as unset (null)", () => {
|
|
392
|
+
const entity = createEntity({
|
|
393
|
+
table: "Test",
|
|
394
|
+
fields: { locale: createSelectField({ options: ["de", "en", "fr"] as const }) },
|
|
395
|
+
});
|
|
396
|
+
const schema = buildInsertSchema(entity);
|
|
397
|
+
const result = schema.safeParse({ locale: "" });
|
|
398
|
+
expect(result.success).toBe(true);
|
|
399
|
+
if (result.success) {
|
|
400
|
+
expect(result.data["locale"]).toBeNull();
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("optional select without default still validates a real value", () => {
|
|
405
|
+
const entity = createEntity({
|
|
406
|
+
table: "Test",
|
|
407
|
+
fields: { locale: createSelectField({ options: ["de", "en", "fr"] as const }) },
|
|
408
|
+
});
|
|
409
|
+
const schema = buildInsertSchema(entity);
|
|
410
|
+
expect(schema.safeParse({ locale: "en" }).success).toBe(true);
|
|
411
|
+
expect(schema.safeParse({ locale: "xx" }).success).toBe(false);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("required select rejects empty string", () => {
|
|
415
|
+
const entity = createEntity({
|
|
416
|
+
table: "Test",
|
|
417
|
+
fields: { locale: createSelectField({ options: ["de", "en"] as const, required: true }) },
|
|
418
|
+
});
|
|
419
|
+
const schema = buildInsertSchema(entity);
|
|
420
|
+
expect(schema.safeParse({ locale: "" }).success).toBe(false);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// #1702: same ""-problem as #1674, but for a select WITH a default —
|
|
424
|
+
// an untouched <select> sends "" and the default only kicks in for
|
|
425
|
+
// undefined. "" maps to the default (a defaulted field is never unset).
|
|
426
|
+
test("optional select with default accepts empty string and falls back to the default", () => {
|
|
427
|
+
const entity = createEntity({
|
|
428
|
+
table: "Test",
|
|
429
|
+
fields: {
|
|
430
|
+
locale: createSelectField({ options: ["de", "en", "fr"] as const, default: "de" }),
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
const schema = buildInsertSchema(entity);
|
|
434
|
+
const result = schema.safeParse({ locale: "" });
|
|
435
|
+
expect(result.success).toBe(true);
|
|
436
|
+
if (result.success) {
|
|
437
|
+
expect(result.data["locale"]).toBe("de");
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("optional select with default still validates a real value", () => {
|
|
442
|
+
const entity = createEntity({
|
|
443
|
+
table: "Test",
|
|
444
|
+
fields: {
|
|
445
|
+
locale: createSelectField({ options: ["de", "en"] as const, default: "de" }),
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
const schema = buildInsertSchema(entity);
|
|
449
|
+
expect(schema.safeParse({ locale: "en" }).success).toBe(true);
|
|
450
|
+
expect(schema.safeParse({ locale: "xx" }).success).toBe(false);
|
|
451
|
+
});
|
|
387
452
|
});
|
|
388
453
|
|
|
389
454
|
// --- Update schema (all partial) ---
|
|
@@ -422,4 +487,56 @@ describe("buildUpdateSchema", () => {
|
|
|
422
487
|
expect(schema.safeParse({ email: "valid@test.de" }).success).toBe(true);
|
|
423
488
|
expect(schema.safeParse({ email: "not-email" }).success).toBe(false);
|
|
424
489
|
});
|
|
490
|
+
|
|
491
|
+
// #1674: clearing a previously-set optional select back to "unset" must
|
|
492
|
+
// work through the update path too — "" from an untouched <select> maps to
|
|
493
|
+
// an explicit `null`, not `undefined` (which would be dropped from the
|
|
494
|
+
// changes payload and silently no-op instead of clearing).
|
|
495
|
+
test("optional select accepts empty string as an explicit clear-to-null", () => {
|
|
496
|
+
const entity = createEntity({
|
|
497
|
+
table: "Test",
|
|
498
|
+
fields: { locale: createSelectField({ options: ["de", "en"] as const }) },
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const schema = buildUpdateSchema(entity);
|
|
502
|
+
const result = schema.safeParse({ locale: "" });
|
|
503
|
+
expect(result.success).toBe(true);
|
|
504
|
+
if (result.success) {
|
|
505
|
+
expect(result.data["locale"]).toBeNull();
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("omitting an optional select on update leaves it untouched", () => {
|
|
510
|
+
const entity = createEntity({
|
|
511
|
+
table: "Test",
|
|
512
|
+
fields: { locale: createSelectField({ options: ["de", "en"] as const }) },
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const schema = buildUpdateSchema(entity);
|
|
516
|
+
const result = schema.safeParse({});
|
|
517
|
+
expect(result.success).toBe(true);
|
|
518
|
+
if (result.success) {
|
|
519
|
+
expect(Object.hasOwn(result.data, "locale")).toBe(false);
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// Update schemas strip defaults deliberately (buildUpdateSchema never
|
|
524
|
+
// applies them — omitting a field must leave it untouched). So "" maps
|
|
525
|
+
// to the same explicit clear-to-null as the no-default case; only the
|
|
526
|
+
// insert path falls back to the default (#1702).
|
|
527
|
+
test("optional select with default on update: empty string is a clear-to-null, not the default", () => {
|
|
528
|
+
const entity = createEntity({
|
|
529
|
+
table: "Test",
|
|
530
|
+
fields: {
|
|
531
|
+
locale: createSelectField({ options: ["de", "en"] as const, default: "de" }),
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
const schema = buildUpdateSchema(entity);
|
|
536
|
+
const result = schema.safeParse({ locale: "" });
|
|
537
|
+
expect(result.success).toBe(true);
|
|
538
|
+
if (result.success) {
|
|
539
|
+
expect(result.data["locale"]).toBeNull();
|
|
540
|
+
}
|
|
541
|
+
});
|
|
425
542
|
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import type { FeatureDefinition } from "../../types";
|
|
3
|
+
import { warnOnUniqueAccessRoles } from "../access-roles";
|
|
4
|
+
|
|
5
|
+
function fakeFeature(overrides: Partial<FeatureDefinition> & { name: string }): FeatureDefinition {
|
|
6
|
+
return {
|
|
7
|
+
writeHandlers: {},
|
|
8
|
+
queryHandlers: {},
|
|
9
|
+
streamHandlers: {},
|
|
10
|
+
...overrides,
|
|
11
|
+
} as unknown as FeatureDefinition;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("warnOnUniqueAccessRoles", () => {
|
|
15
|
+
let warnSpy: ReturnType<typeof spyOn<Console, "warn">>;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
warnSpy = spyOn(console, "warn");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
warnSpy.mockRestore();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("warns when a role is used by exactly one handler", () => {
|
|
26
|
+
const features = [
|
|
27
|
+
fakeFeature({
|
|
28
|
+
name: "users",
|
|
29
|
+
writeHandlers: {
|
|
30
|
+
"create-user": { name: "create-user", access: { roles: ["Admin"] } },
|
|
31
|
+
} as unknown as FeatureDefinition["writeHandlers"],
|
|
32
|
+
}),
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
warnOnUniqueAccessRoles(features);
|
|
36
|
+
|
|
37
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
38
|
+
const msg = warnSpy.mock.calls[0]![0] as string;
|
|
39
|
+
expect(msg).toContain("Admin");
|
|
40
|
+
expect(msg).toContain("users:write:create-user");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("does NOT warn when the same role is used by two different handlers", () => {
|
|
44
|
+
const features = [
|
|
45
|
+
fakeFeature({
|
|
46
|
+
name: "users",
|
|
47
|
+
writeHandlers: {
|
|
48
|
+
"create-user": { name: "create-user", access: { roles: ["Admin"] } },
|
|
49
|
+
} as unknown as FeatureDefinition["writeHandlers"],
|
|
50
|
+
}),
|
|
51
|
+
fakeFeature({
|
|
52
|
+
name: "billing",
|
|
53
|
+
queryHandlers: {
|
|
54
|
+
"list-invoices": { name: "list-invoices", access: { roles: ["Admin"] } },
|
|
55
|
+
} as unknown as FeatureDefinition["queryHandlers"],
|
|
56
|
+
}),
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
warnOnUniqueAccessRoles(features);
|
|
60
|
+
|
|
61
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("does NOT warn for built-in roles 'all' or 'system' even when used by one handler", () => {
|
|
65
|
+
const features = [
|
|
66
|
+
fakeFeature({
|
|
67
|
+
name: "misc",
|
|
68
|
+
writeHandlers: {
|
|
69
|
+
"open-endpoint": { name: "open-endpoint", access: { roles: ["all"] } },
|
|
70
|
+
} as unknown as FeatureDefinition["writeHandlers"],
|
|
71
|
+
}),
|
|
72
|
+
fakeFeature({
|
|
73
|
+
name: "admin-tools",
|
|
74
|
+
streamHandlers: {
|
|
75
|
+
"audit-log": { name: "audit-log", access: { roles: ["system"] } },
|
|
76
|
+
} as unknown as FeatureDefinition["streamHandlers"],
|
|
77
|
+
}),
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
warnOnUniqueAccessRoles(features);
|
|
81
|
+
|
|
82
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { FeatureDefinition } from "../types";
|
|
2
|
+
|
|
3
|
+
const BUILTIN_ROLES = new Set(["all", "system"]);
|
|
4
|
+
|
|
5
|
+
export function warnOnUniqueAccessRoles(features: readonly FeatureDefinition[]): void {
|
|
6
|
+
// role → set of distinct handler identifiers using it
|
|
7
|
+
const roleHandlers = new Map<string, Set<string>>();
|
|
8
|
+
|
|
9
|
+
for (const f of features) {
|
|
10
|
+
const handlerGroups = [
|
|
11
|
+
{ type: "write", defs: f.writeHandlers },
|
|
12
|
+
{ type: "query", defs: f.queryHandlers },
|
|
13
|
+
{ type: "stream", defs: f.streamHandlers },
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
for (const { type, defs } of handlerGroups) {
|
|
17
|
+
for (const [handlerName, def] of Object.entries(defs)) {
|
|
18
|
+
if (!def.access || !("roles" in def.access)) continue;
|
|
19
|
+
const identifier = `${f.name}:${type}:${handlerName}`;
|
|
20
|
+
for (const role of def.access.roles) {
|
|
21
|
+
let handlers = roleHandlers.get(role);
|
|
22
|
+
if (!handlers) {
|
|
23
|
+
handlers = new Set();
|
|
24
|
+
roleHandlers.set(role, handlers);
|
|
25
|
+
}
|
|
26
|
+
handlers.add(identifier);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const [role, handlers] of roleHandlers) {
|
|
33
|
+
if (BUILTIN_ROLES.has(role)) continue;
|
|
34
|
+
if (handlers.size !== 1) continue;
|
|
35
|
+
const [identifier] = handlers;
|
|
36
|
+
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
37
|
+
console.warn(
|
|
38
|
+
`[kumiko:boot] Access role "${role}" is only used by one handler (${identifier}). ` +
|
|
39
|
+
`This is often a typo — the role is unknown to every other handler in the boot scan. ` +
|
|
40
|
+
`If this is intentional, ignore this warning.`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -53,6 +53,27 @@ export const PII_USER_OWNED_NAME_HINTS: ReadonlySet<string> = new Set([
|
|
|
53
53
|
"notes",
|
|
54
54
|
]);
|
|
55
55
|
|
|
56
|
+
// Field names that typically hold a FK into the `user` entity — carry no
|
|
57
|
+
// content of their own, but the subject-data obligation attaches to the
|
|
58
|
+
// link, not to annotated content. Without this hint, an entity with no
|
|
59
|
+
// userOwned-content field stays invisible to the GDPR-hook-coverage guard
|
|
60
|
+
// even though `authorId` is still personal data.
|
|
61
|
+
export const PII_USER_REFERENCE_NAME_HINTS: ReadonlySet<string> = new Set([
|
|
62
|
+
"authorid",
|
|
63
|
+
"assigneeid",
|
|
64
|
+
"ownerid",
|
|
65
|
+
"createdbyid",
|
|
66
|
+
"createdbyuserid",
|
|
67
|
+
"updatedbyid",
|
|
68
|
+
"updatedbyuserid",
|
|
69
|
+
"invitedby",
|
|
70
|
+
"approvedby",
|
|
71
|
+
"reviewedby",
|
|
72
|
+
"uploadedby",
|
|
73
|
+
"assignedto",
|
|
74
|
+
"reportedby",
|
|
75
|
+
]);
|
|
76
|
+
|
|
56
77
|
// --- Extension preSave wiring validation ---
|
|
57
78
|
|
|
58
79
|
/** Extensions with preSave must target an entity that has mapped write handlers. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption";
|
|
2
2
|
import { QnTypes, qualifyEntityName } from "../qualified-name";
|
|
3
3
|
import type { ClaimKeyDefinition, FeatureDefinition } from "../types";
|
|
4
|
+
import { warnOnUniqueAccessRoles } from "./access-roles";
|
|
4
5
|
import { validateActionWiring, validateFieldWiring } from "./action-wiring";
|
|
5
6
|
import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext";
|
|
6
7
|
import { validateFeatureBootChecks } from "./boot-check";
|
|
@@ -212,4 +213,5 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
|
|
|
212
213
|
|
|
213
214
|
validateConfigReads(features, allConfigKeys);
|
|
214
215
|
warnOnToggleableDependencies(features, featureMap);
|
|
216
|
+
warnOnUniqueAccessRoles(features);
|
|
215
217
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { FeatureDefinition } from "../types";
|
|
2
2
|
import type { FieldAccess, PiiAnnotations } from "../types/fields";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
PII_DIRECT_NAME_HINTS,
|
|
5
|
+
PII_USER_OWNED_NAME_HINTS,
|
|
6
|
+
PII_USER_REFERENCE_NAME_HINTS,
|
|
7
|
+
} from "./entity-handler";
|
|
4
8
|
|
|
5
9
|
// Framework-managed Timestamp-Spalten — dürfen als retention.reference
|
|
6
10
|
// genutzt werden auch wenn nicht in entity.fields deklariert.
|
|
@@ -208,6 +212,11 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
208
212
|
console.warn(
|
|
209
213
|
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-content-typical name but no { userOwned } annotation. If this contains user-generated content, mark it { userOwned: { ownerField: "<authorIdField>" }}. If business data, set { allowPlaintext: "..." } to silence.`,
|
|
210
214
|
);
|
|
215
|
+
} else if (PII_USER_REFERENCE_NAME_HINTS.has(lower) && !annot.subjectRef) {
|
|
216
|
+
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
217
|
+
console.warn(
|
|
218
|
+
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no { subjectRef: true } annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { subjectRef: true }, or { userOwned: { ownerField: "${fieldName}" } } on the field it owns. If business data, set { allowPlaintext: "..." } to silence.`,
|
|
219
|
+
);
|
|
211
220
|
}
|
|
212
221
|
}
|
|
213
222
|
}
|
|
@@ -575,6 +575,48 @@ export function validateScreens(
|
|
|
575
575
|
`the row field that names the target entity's id.`,
|
|
576
576
|
);
|
|
577
577
|
}
|
|
578
|
+
// params only have a reader in actionForm and entityEdit-CREATE;
|
|
579
|
+
// on projectionDetail/dashboard/configEdit/entityEdit-update they
|
|
580
|
+
// are silently ignored at runtime. `custom` is deliberately
|
|
581
|
+
// exempt: it renders an app-registered component the framework
|
|
582
|
+
// has no visibility into — the author may read nav.searchParams
|
|
583
|
+
// directly (real example: publicstatus's MonitorDetailScreen
|
|
584
|
+
// does exactly that), so flagging it would be a false positive
|
|
585
|
+
// on working code, not a caught bug.
|
|
586
|
+
//
|
|
587
|
+
// Whether an entityEdit target lands in create or update mode is
|
|
588
|
+
// decided by the same rule the renderer's runNavigate() uses: an
|
|
589
|
+
// explicit entityId always forces update mode, and (absent an
|
|
590
|
+
// explicit entityId) a same-entity target gets row["id"] auto-
|
|
591
|
+
// injected — only a cross-entity target with no explicit
|
|
592
|
+
// entityId reaches create.
|
|
593
|
+
if (
|
|
594
|
+
action.params !== undefined &&
|
|
595
|
+
target !== undefined &&
|
|
596
|
+
target.screen.type !== "custom"
|
|
597
|
+
) {
|
|
598
|
+
const isEntityEditUpdate =
|
|
599
|
+
target.screen.type === "entityEdit" &&
|
|
600
|
+
(action.entityId !== undefined || target.screen.entity === screen.entity);
|
|
601
|
+
if (
|
|
602
|
+
(target.screen.type !== "actionForm" && target.screen.type !== "entityEdit") ||
|
|
603
|
+
isEntityEditUpdate
|
|
604
|
+
) {
|
|
605
|
+
const reason = isEntityEditUpdate
|
|
606
|
+
? `resolves to UPDATE mode (${
|
|
607
|
+
action.entityId !== undefined
|
|
608
|
+
? `explicit entityId "${action.entityId}"`
|
|
609
|
+
: `same entity "${screen.entity}" auto-fills row["id"]`
|
|
610
|
+
})`
|
|
611
|
+
: `screen type "${target.screen.type}"`;
|
|
612
|
+
throw new Error(
|
|
613
|
+
`[Feature ${feature.name}] Screen "${screenId}" (entityList) rowAction "${action.id}" ` +
|
|
614
|
+
`sets params on navigate-target "${action.screen}" which ${reason} — only actionForm ` +
|
|
615
|
+
`and entityEdit-create targets read URL search params as initial values. Remove the ` +
|
|
616
|
+
`params extractor or retarget to an actionForm / cross-entity entityEdit-create screen.`,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
578
620
|
} else {
|
|
579
621
|
if (!allWriteHandlerQns.has(action.handler)) {
|
|
580
622
|
throw new Error(
|
package/src/engine/index.ts
CHANGED
|
@@ -202,7 +202,12 @@ export {
|
|
|
202
202
|
stripForbiddenMembershipRoles,
|
|
203
203
|
} from "./membership-roles";
|
|
204
204
|
export type { OwnershipClause, OwnershipMap, OwnershipRef, OwnershipRule } from "./ownership";
|
|
205
|
-
export {
|
|
205
|
+
export {
|
|
206
|
+
buildOwnershipClause,
|
|
207
|
+
from,
|
|
208
|
+
userCanReadFieldRow,
|
|
209
|
+
userCanWriteFieldRow,
|
|
210
|
+
} from "./ownership";
|
|
206
211
|
export { buildPipelineSteps, stepsPipeline } from "./pipeline";
|
|
207
212
|
export { defineApply, defineMspApply, setFields } from "./projection-helpers";
|
|
208
213
|
export type { BuiltinQnType, ParsedQn, QnType } from "./qualified-name";
|
|
@@ -80,8 +80,22 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
|
|
|
80
80
|
case "select": {
|
|
81
81
|
const [first, ...rest] = field.options;
|
|
82
82
|
if (!first) return z.string();
|
|
83
|
-
const
|
|
84
|
-
|
|
83
|
+
const enumSchema = z.enum([first, ...rest]);
|
|
84
|
+
if (field.default !== undefined)
|
|
85
|
+
// Untouched <select> sends "" too; with a default that maps to the
|
|
86
|
+
// default (same semantics as undefined) instead of the invalid-value
|
|
87
|
+
// rejection from #1702. A field with a default is never "unset".
|
|
88
|
+
return z.preprocess(
|
|
89
|
+
(value) => (value === "" ? field.default : value),
|
|
90
|
+
enumSchema.default(field.default),
|
|
91
|
+
);
|
|
92
|
+
if (field.required) return enumSchema;
|
|
93
|
+
// Optional select without a default: an untouched HTML <select> submits
|
|
94
|
+
// "" for its placeholder option. Treat that as "unset" (null) instead of
|
|
95
|
+
// an invalid enum value — null (not undefined) so the value survives the
|
|
96
|
+
// JSON-serialized event payload and an update can actually clear a
|
|
97
|
+
// previously-set select back to unset, not just skip validation.
|
|
98
|
+
return z.preprocess((value) => (value === "" ? null : value), enumSchema.nullable());
|
|
85
99
|
}
|
|
86
100
|
case "multiSelect": {
|
|
87
101
|
const [first, ...rest] = field.options;
|
package/src/schema-cli.ts
CHANGED
|
@@ -287,9 +287,22 @@ export async function runSchemaCli(
|
|
|
287
287
|
for (const m of mismatches) {
|
|
288
288
|
out.err(` ${m.tableName} (${m.kind}): ${m.detail}`);
|
|
289
289
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
290
|
+
if (mismatches.some((m) => m.kind === "unexpected-table")) {
|
|
291
|
+
out.err(
|
|
292
|
+
" Fix (unexpected-table): register the raw/hand-written table via `table()` " +
|
|
293
|
+
"(or `defineUnmanagedTable()`) from `@cosmicdrift/kumiko-framework/db`, then " +
|
|
294
|
+
"`r.storeTable(meta, { reason: ... })` inside a feature — this adds it to " +
|
|
295
|
+
"ENTITY_METAS and the snapshot without going through r.entity(). See the " +
|
|
296
|
+
"bundled `jobs` feature's job-run-log store table for the pattern.",
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (mismatches.some((m) => m.kind !== "unexpected-table")) {
|
|
300
|
+
out.err(
|
|
301
|
+
" Fix (missing-table/column-drift): a migration file's body doesn't match " +
|
|
302
|
+
"what it (or the snapshot) claims — hand-fix the file, or ship a corrective " +
|
|
303
|
+
"migration if it's already applied in prod.",
|
|
304
|
+
);
|
|
305
|
+
}
|
|
293
306
|
}
|
|
294
307
|
} catch (e) {
|
|
295
308
|
// replayMigrationsDir fail-loud's on a table-DDL statement it can't
|
|
@@ -62,12 +62,15 @@ function ensureEntityListRowNavigation(
|
|
|
62
62
|
...screen,
|
|
63
63
|
rowActions: [
|
|
64
64
|
...(screen.rowActions ?? []),
|
|
65
|
+
// No `params` here: this same-entity target always resolves to
|
|
66
|
+
// UPDATE mode (the renderer auto-fills entityId from row["id"]),
|
|
67
|
+
// and entityEdit-update never reads search-params as initial
|
|
68
|
+
// values — the boot-validator now rejects that combination (#1680).
|
|
65
69
|
{
|
|
66
70
|
kind: "navigate",
|
|
67
71
|
id: "edit",
|
|
68
72
|
label: "stub:edit",
|
|
69
73
|
screen: editScreen.id,
|
|
70
|
-
params: { pick: ["id"] },
|
|
71
74
|
},
|
|
72
75
|
],
|
|
73
76
|
};
|