@cosmicdrift/kumiko-framework 0.97.0 → 0.98.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.98.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"zod": "^4.4.3"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.98.0",
|
|
185
185
|
"bun-types": "^1.3.13",
|
|
186
186
|
"pino-pretty": "^13.1.3"
|
|
187
187
|
},
|
|
@@ -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", () => {
|
|
@@ -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,
|