@cosmicdrift/kumiko-framework 0.97.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.97.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.97.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
  },
@@ -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
- // (job, MSP-apply) — no client IP to bucket on. Pass through;
717
- // L1/L2 middleware handle the HTTP-side ip caps.
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,