@cosmicdrift/kumiko-framework 0.96.0 → 0.97.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 +2 -2
- package/src/engine/__tests__/boot-validator.test.ts +41 -0
- package/src/engine/__tests__/build-app-schema.test.ts +35 -0
- package/src/engine/boot-validator/screens-nav.ts +24 -4
- package/src/engine/build-app-schema.ts +18 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +26 -0
- package/src/pipeline/dispatcher.ts +11 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.97.1",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"zod": "^4.4.3"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.97.1",
|
|
185
185
|
"bun-types": "^1.3.13",
|
|
186
186
|
"pino-pretty": "^13.1.3"
|
|
187
187
|
},
|
|
@@ -1920,6 +1920,28 @@ describe("boot-validator", () => {
|
|
|
1920
1920
|
/rowAction "edit" navigate-target "ghost-screen" does not resolve/,
|
|
1921
1921
|
);
|
|
1922
1922
|
});
|
|
1923
|
+
|
|
1924
|
+
test("navigate-target → screen in ANOTHER feature → kein Throw (cross-feature)", () => {
|
|
1925
|
+
// Eine deklarative Liste im owning-Feature der Entity navigiert zum
|
|
1926
|
+
// Custom-Editor der Consumer-App (anderes Feature). Runtime löst die bare
|
|
1927
|
+
// id app-weit auf → der Validator muss das erlauben.
|
|
1928
|
+
const list = defineFeature("shop", (r) => {
|
|
1929
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
1930
|
+
r.screen({
|
|
1931
|
+
id: "product-list",
|
|
1932
|
+
type: "entityList",
|
|
1933
|
+
entity: "product",
|
|
1934
|
+
columns: ["name"],
|
|
1935
|
+
rowActions: [
|
|
1936
|
+
{ kind: "navigate", id: "edit", label: "actions.edit", screen: "product-editor" },
|
|
1937
|
+
],
|
|
1938
|
+
});
|
|
1939
|
+
});
|
|
1940
|
+
const consumer = defineFeature("app", (r) => {
|
|
1941
|
+
r.screen({ id: "product-editor", type: "custom", renderer: { react: "stub" } });
|
|
1942
|
+
});
|
|
1943
|
+
expect(() => validateBoot([list, consumer])).not.toThrow();
|
|
1944
|
+
});
|
|
1923
1945
|
});
|
|
1924
1946
|
|
|
1925
1947
|
// --- rowAction kind="writeHandler" handler-QN-Validierung (Tier 2.7e-1 erw.) ---
|
|
@@ -2125,6 +2147,25 @@ describe("boot-validator", () => {
|
|
|
2125
2147
|
/toolbarAction "open-form" navigate-target "ghost-form" does not resolve/,
|
|
2126
2148
|
);
|
|
2127
2149
|
});
|
|
2150
|
+
|
|
2151
|
+
test("navigate-target → screen in ANOTHER feature → kein Throw (cross-feature)", () => {
|
|
2152
|
+
const list = defineFeature("shop", (r) => {
|
|
2153
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
2154
|
+
r.screen({
|
|
2155
|
+
id: "product-list",
|
|
2156
|
+
type: "entityList",
|
|
2157
|
+
entity: "product",
|
|
2158
|
+
columns: ["name"],
|
|
2159
|
+
toolbarActions: [
|
|
2160
|
+
{ kind: "navigate", id: "new", label: "actions.new", screen: "product-editor" },
|
|
2161
|
+
],
|
|
2162
|
+
});
|
|
2163
|
+
});
|
|
2164
|
+
const consumer = defineFeature("app", (r) => {
|
|
2165
|
+
r.screen({ id: "product-editor", type: "custom", renderer: { react: "stub" } });
|
|
2166
|
+
});
|
|
2167
|
+
expect(() => validateBoot([list, consumer])).not.toThrow();
|
|
2168
|
+
});
|
|
2128
2169
|
});
|
|
2129
2170
|
|
|
2130
2171
|
describe("entityList toolbarAction writeHandler handler-QN (Tier 2.7e-2)", () => {
|
|
@@ -255,6 +255,41 @@ describe("buildAppSchema", () => {
|
|
|
255
255
|
});
|
|
256
256
|
expect(actions?.find((a) => a.id === "always")?.visible).toBe(true);
|
|
257
257
|
});
|
|
258
|
+
|
|
259
|
+
test("derivedFields werden ins Client-Schema projiziert (valueType, ohne derive-fn)", () => {
|
|
260
|
+
// Regression: projectEntity ließ derivedFields ganz weg → der Client kannte
|
|
261
|
+
// sie nicht, computeListViewModel warf "references unknown field" für jede
|
|
262
|
+
// derived entityList-Spalte (z.B. bauspar `phase`). Der executor hängt den
|
|
263
|
+
// Wert server-seitig an die Row; der Client braucht nur den valueType.
|
|
264
|
+
const contractEntity = {
|
|
265
|
+
table: "contracts",
|
|
266
|
+
fields: { name: { type: "text" } },
|
|
267
|
+
derivedFields: {
|
|
268
|
+
phase: { valueType: "text", derive: () => "saving" },
|
|
269
|
+
balance: { valueType: "decimal", derive: () => 0 },
|
|
270
|
+
},
|
|
271
|
+
} as unknown as EntityDefinition;
|
|
272
|
+
|
|
273
|
+
const f = defineFeature("credit", (r) => {
|
|
274
|
+
r.entity("contract", contractEntity);
|
|
275
|
+
r.screen({
|
|
276
|
+
id: "list",
|
|
277
|
+
type: "entityList",
|
|
278
|
+
entity: "contract",
|
|
279
|
+
columns: ["name", "phase", "balance"],
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const app = buildAppSchema(createRegistry([f]));
|
|
284
|
+
const entity = app.features.find((feat) => feat.featureName === "credit")?.entities["contract"];
|
|
285
|
+
|
|
286
|
+
expect(entity?.derivedFields?.["phase"]?.valueType).toBe("text");
|
|
287
|
+
expect(entity?.derivedFields?.["balance"]?.valueType).toBe("decimal");
|
|
288
|
+
// derive-fn ist Server-only — darf NICHT durchkommen (sonst Funktions-Leak
|
|
289
|
+
// im Browser-Bundle, den die JSON-Safety-Guard fängt).
|
|
290
|
+
expect(entity?.derivedFields?.["phase"]).not.toHaveProperty("derive");
|
|
291
|
+
expect(findNonJsonSafePath(app, "schema")).toBeNull();
|
|
292
|
+
});
|
|
258
293
|
});
|
|
259
294
|
|
|
260
295
|
describe("findNonJsonSafePath", () => {
|
|
@@ -88,6 +88,13 @@ export function validateScreens(
|
|
|
88
88
|
allScreenQns: ReadonlySet<string>,
|
|
89
89
|
allConfigKeyQns: ReadonlySet<string>,
|
|
90
90
|
): void {
|
|
91
|
+
// navigate-Targets (rowAction/toolbarAction) dürfen cross-feature zeigen —
|
|
92
|
+
// der Runtime-Router (create-app) löst eine bare screenId app-weit über ALLE
|
|
93
|
+
// Features auf (eine deklarative Liste im owning-Feature der Entity navigiert
|
|
94
|
+
// so zu den Custom-Editoren der Consumer-App). Der Validator spiegelt das:
|
|
95
|
+
// same-feature ODER irgendein Feature. (redirect/cancelTarget bleiben bewusst
|
|
96
|
+
// same-feature: deren Router baut die URL direkt aus der kurzen id.)
|
|
97
|
+
const navTargetShortIds = screenShortIdsFrom(allScreenQns);
|
|
91
98
|
for (const [screenId, screen] of Object.entries(feature.screens)) {
|
|
92
99
|
if (screen.type === "custom") {
|
|
93
100
|
if (!screen.renderer.react && !screen.renderer.native) {
|
|
@@ -429,10 +436,10 @@ export function validateScreens(
|
|
|
429
436
|
for (const action of screen.rowActions) {
|
|
430
437
|
if (action.kind === "navigate") {
|
|
431
438
|
const candidateQn = qualifyEntityName(feature.name, "screen", action.screen);
|
|
432
|
-
if (!allScreenQns.has(candidateQn)) {
|
|
439
|
+
if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
|
|
433
440
|
throw new Error(
|
|
434
441
|
`[Feature ${feature.name}] Screen "${screenId}" (entityList) rowAction "${action.id}" ` +
|
|
435
|
-
`navigate-target "${action.screen}" does not resolve to a registered screen in
|
|
442
|
+
`navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
|
|
436
443
|
);
|
|
437
444
|
}
|
|
438
445
|
} else {
|
|
@@ -462,10 +469,10 @@ export function validateScreens(
|
|
|
462
469
|
for (const action of screen.toolbarActions) {
|
|
463
470
|
if (action.kind === "navigate") {
|
|
464
471
|
const candidateQn = qualifyEntityName(feature.name, "screen", action.screen);
|
|
465
|
-
if (!allScreenQns.has(candidateQn)) {
|
|
472
|
+
if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
|
|
466
473
|
throw new Error(
|
|
467
474
|
`[Feature ${feature.name}] Screen "${screenId}" (entityList) toolbarAction "${action.id}" ` +
|
|
468
|
-
`navigate-target "${action.screen}" does not resolve to a registered screen in
|
|
475
|
+
`navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
|
|
469
476
|
);
|
|
470
477
|
}
|
|
471
478
|
} else {
|
|
@@ -615,6 +622,19 @@ export function collectScreenQns(features: readonly FeatureDefinition[]): Set<st
|
|
|
615
622
|
return set;
|
|
616
623
|
}
|
|
617
624
|
|
|
625
|
+
// Bare Screen-ids (ohne `<feature>:screen:`-Prefix) aus den qualifizierten
|
|
626
|
+
// QNs — für die app-weite Auflösung von navigate-Targets (s. validateScreens).
|
|
627
|
+
// Spiegelt den Runtime-Router, der bare ids feature-übergreifend matcht.
|
|
628
|
+
export function screenShortIdsFrom(allScreenQns: ReadonlySet<string>): Set<string> {
|
|
629
|
+
const marker = ":screen:";
|
|
630
|
+
const set = new Set<string>();
|
|
631
|
+
for (const qn of allScreenQns) {
|
|
632
|
+
const at = qn.indexOf(marker);
|
|
633
|
+
if (at !== -1) set.add(qn.slice(at + marker.length));
|
|
634
|
+
}
|
|
635
|
+
return set;
|
|
636
|
+
}
|
|
637
|
+
|
|
618
638
|
// Sammelt alle qualifizierten Write-Handler-QNs (`<feature>:write:<short>`).
|
|
619
639
|
// Wird vom actionForm-Screen-Validator genutzt um zu prüfen ob der
|
|
620
640
|
// im Schema deklarierte handler tatsächlich registriert ist —
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
SETTINGS_HUB_FEATURE,
|
|
33
33
|
} from "./build-config-feature-schema";
|
|
34
34
|
import type { Registry } from "./types/feature";
|
|
35
|
-
import type { FieldDefinition } from "./types/fields";
|
|
35
|
+
import type { DerivedFieldDef, FieldDefinition } from "./types/fields";
|
|
36
36
|
|
|
37
37
|
export type BuildAppSchemaOptions = {
|
|
38
38
|
/** Dev-server authoring hints (Settings-Hub placement). Default off — only
|
|
@@ -285,16 +285,33 @@ function projectEntity(entity: EntityDefinition): EntityDefinition {
|
|
|
285
285
|
for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
|
|
286
286
|
fieldsOut[fieldName] = projectField(fieldDef);
|
|
287
287
|
}
|
|
288
|
+
// derivedFields MÜSSEN mit ins Client-Schema (nur die Metadaten, nicht die
|
|
289
|
+
// derive-fn): computeListViewModel löst eine entityList-Spalte über
|
|
290
|
+
// `entity.derivedFields[field].valueType` auf — fehlt der Eintrag, wirft es
|
|
291
|
+
// "references unknown field". Der executor hat den Wert server-seitig schon
|
|
292
|
+
// an die Row gehängt; der Client braucht nur den valueType für den Renderer.
|
|
293
|
+
const derivedOut: Record<string, DerivedFieldDef> = {};
|
|
294
|
+
for (const [name, derivedDef] of Object.entries(entity.derivedFields ?? {})) {
|
|
295
|
+
derivedOut[name] = projectDerivedField(derivedDef);
|
|
296
|
+
}
|
|
288
297
|
// EntityDefinition akzeptiert idType/access/searchWeight als optional —
|
|
289
298
|
// wir lassen die weg weil der Browser-Renderer sie nicht liest. `table`
|
|
290
299
|
// schicken wir mit, falls Apps `entity.table` direkt referenzieren.
|
|
291
300
|
// Kein Cast nötig: alle weggelassenen Felder sind `?`-optional.
|
|
292
301
|
return {
|
|
293
302
|
fields: fieldsOut,
|
|
303
|
+
...(Object.keys(derivedOut).length > 0 && { derivedFields: derivedOut }),
|
|
294
304
|
...(typeof entity.table === "string" && { table: entity.table }),
|
|
295
305
|
};
|
|
296
306
|
}
|
|
297
307
|
|
|
308
|
+
// Nur valueType durch — die derive-fn ist Server-only und NICHT JSON-safe
|
|
309
|
+
// (würde sonst die Output-Walk-Guard triggern). Der Cast bridged die
|
|
310
|
+
// fn-lose Projektion auf DerivedFieldDef (Client liest nur valueType).
|
|
311
|
+
function projectDerivedField(derivedDef: DerivedFieldDef): DerivedFieldDef {
|
|
312
|
+
return { valueType: derivedDef.valueType } as DerivedFieldDef; // @cast-boundary schema-walk
|
|
313
|
+
}
|
|
314
|
+
|
|
298
315
|
// Whitelist pro Field. `default` darf nur durch wenn Literal (string/
|
|
299
316
|
// number/boolean/null) — auch wenn die FieldDefinition-Types „default"
|
|
300
317
|
// nur als Literal typisieren, hat das Sample-Pattern
|
|
@@ -74,6 +74,32 @@ describe("dispatcher.write", () => {
|
|
|
74
74
|
}
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
+
test("ip-bucketed handler with no IP + no resolver skips rate-limit (es-ops seed/job path)", async () => {
|
|
78
|
+
// Regression: the es-ops seed/job dispatcher has no RateLimitResolver. An
|
|
79
|
+
// ip-bucketed handler invoked from there has no client IP to bucket on, so
|
|
80
|
+
// it must SKIP the rate-limit — not throw "no RateLimitResolver is
|
|
81
|
+
// configured". (The HTTP path still has the resolver for real anon writes.)
|
|
82
|
+
const rlFeature = defineFeature("rl", (r) => {
|
|
83
|
+
r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
|
|
84
|
+
r.writeHandler(
|
|
85
|
+
"item:create",
|
|
86
|
+
z.object({ name: z.string() }),
|
|
87
|
+
async (event) => ({ isSuccess: true, data: { name: event.payload.name } }),
|
|
88
|
+
{
|
|
89
|
+
access: { openToAll: true },
|
|
90
|
+
rateLimit: { per: "ip+handler", limit: 3, windowSeconds: 60 },
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
const dispatcher = createDispatcher(createRegistry([rlFeature]), {});
|
|
95
|
+
const result = await dispatcher.write(
|
|
96
|
+
"rl:write:item:create",
|
|
97
|
+
{ name: "seeded" },
|
|
98
|
+
createTestUser(),
|
|
99
|
+
);
|
|
100
|
+
expect(result.isSuccess).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
77
103
|
test("ctx.user ist Convenience-Alias auf event.user (gleicher Wert)", async () => {
|
|
78
104
|
// Pinst dass der Handler auf ctx.user zugreifen kann ohne den
|
|
79
105
|
// typo-resistenten event.user-Pfad zu nutzen. Identitätsprüfung
|
|
@@ -701,21 +701,24 @@ export function createDispatcher(
|
|
|
701
701
|
// handler.rateLimit !== undefined, so this branch only fires
|
|
702
702
|
// if a future caller forgets the inline check.
|
|
703
703
|
if (!rateLimit) return;
|
|
704
|
-
if (!context.rateLimit) {
|
|
705
|
-
throw new InternalError({
|
|
706
|
-
message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the rate-limiting feature or remove the option.`,
|
|
707
|
-
});
|
|
708
|
-
}
|
|
709
704
|
const reqCtx = requestContext.get();
|
|
710
705
|
const bucket = buildBucketKey(rateLimit, {
|
|
711
706
|
handlerName,
|
|
712
707
|
user,
|
|
713
708
|
ip: reqCtx?.ip,
|
|
714
709
|
});
|
|
715
|
-
// skip: ip-bucketed handler called from a non-HTTP entry point
|
|
716
|
-
//
|
|
717
|
-
//
|
|
710
|
+
// skip: ip-bucketed handler called from a non-HTTP entry point (job, seed,
|
|
711
|
+
// MSP-apply) — no client IP to bucket on, nothing to enforce. Pass
|
|
712
|
+
// through BEFORE requiring a resolver, so system/seed writes through
|
|
713
|
+
// such a handler don't need a RateLimitResolver wired (the es-ops
|
|
714
|
+
// seed dispatcher has none). L1/L2 middleware handle the HTTP-side
|
|
715
|
+
// ip caps.
|
|
718
716
|
if (bucket.kind === "skip") return;
|
|
717
|
+
if (!context.rateLimit) {
|
|
718
|
+
throw new InternalError({
|
|
719
|
+
message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the rate-limiting feature or remove the option.`,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
719
722
|
await context.rateLimit.enforce(bucket.key, {
|
|
720
723
|
limit: rateLimit.limit,
|
|
721
724
|
windowSeconds: rateLimit.windowSeconds,
|