@cosmicdrift/kumiko-framework 0.201.0 → 0.203.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.
Files changed (34) hide show
  1. package/package.json +7 -3
  2. package/src/api/__tests__/api-constants-completeness.test.ts +63 -0
  3. package/src/api/__tests__/body-limit.test.ts +78 -4
  4. package/src/api/__tests__/server-jwt-ttl.test.ts +2 -2
  5. package/src/api/api-constants.ts +44 -7
  6. package/src/api/auth-middleware.ts +19 -3
  7. package/src/api/index.ts +1 -0
  8. package/src/api/route-registrars.ts +19 -22
  9. package/src/api/server.ts +1 -1
  10. package/src/bun-db/__tests__/sql-expr-brand.test.ts +33 -1
  11. package/src/db/__tests__/list-pagination.test.ts +28 -0
  12. package/src/db/dialect.ts +7 -8
  13. package/src/db/entity-table-meta.ts +4 -3
  14. package/src/db/event-store-executor-read.ts +26 -2
  15. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +22 -0
  16. package/src/engine/__tests__/boot-validator-detail-for.test.ts +82 -0
  17. package/src/engine/__tests__/boot-validator-projection-list.test.ts +191 -0
  18. package/src/engine/__tests__/build-app-schema.test.ts +119 -0
  19. package/src/engine/__tests__/projection-detail-actions.test.ts +137 -0
  20. package/src/engine/boot-validator/action-wiring.ts +7 -1
  21. package/src/engine/boot-validator/detail-screens.ts +35 -0
  22. package/src/engine/boot-validator/index.ts +4 -0
  23. package/src/engine/boot-validator/projection-list-screens.ts +82 -0
  24. package/src/engine/boot-validator/screens.ts +42 -1
  25. package/src/engine/build-app-schema.ts +55 -2
  26. package/src/engine/feature-ast/__tests__/patch.test.ts +58 -0
  27. package/src/engine/feature-ast/patch.ts +22 -2
  28. package/src/files/__tests__/files.integration.test.ts +2 -2
  29. package/src/http/__tests__/egress-real-endpoint.integration.test.ts +37 -0
  30. package/src/http/__tests__/egress.test.ts +440 -0
  31. package/src/http/__tests__/policy.test.ts +125 -0
  32. package/src/http/egress.ts +158 -0
  33. package/src/http/index.ts +2 -0
  34. package/src/http/policy.ts +193 -0
@@ -0,0 +1,82 @@
1
+ import { ZodObject } from "zod";
2
+ import { QnTypes, qualifyEntityName } from "../qualified-name";
3
+ import type { FeatureDefinition, ProjectionListScreenDefinition, QueryHandlerDef } from "../types";
4
+ import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens";
5
+
6
+ // Sibling to entity-list-screens.ts rather than an extension of it:
7
+ // validateOneEntityListScreen is typed to EntityListScreenDefinition and
8
+ // reaches into feature.entities[screen.entity] — a projectionList has no
9
+ // entity, so sharing the function would mean threading a discriminated
10
+ // union through every entity-bound helper it calls.
11
+
12
+ function buildQueryHandlerMap(
13
+ features: readonly FeatureDefinition[],
14
+ ): ReadonlyMap<string, QueryHandlerDef> {
15
+ const out = new Map<string, QueryHandlerDef>();
16
+ for (const feature of features) {
17
+ for (const [name, handler] of Object.entries(feature.queryHandlers ?? {})) {
18
+ out.set(qualifyEntityName(feature.name, QnTypes.query, name), handler);
19
+ }
20
+ }
21
+ return out;
22
+ }
23
+
24
+ // Non-ZodObject schemas (e.g. a z.union across payload shapes) and
25
+ // unresolved query handlers both fall through to "capability absent" —
26
+ // consistent with buildAppSchema's derivation, no throw either way.
27
+ function schemaAccepts(schema: QueryHandlerDef["schema"] | undefined, key: string): boolean {
28
+ const shape = schema instanceof ZodObject ? schema.shape : undefined;
29
+ return shape !== undefined && key in shape;
30
+ }
31
+
32
+ function validateOneProjectionListScreen(
33
+ feature: FeatureDefinition,
34
+ screen: ProjectionListScreenDefinition,
35
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
36
+ ): void {
37
+ const prefix = `[projectionList] Feature "${feature.name}" screen "${screen.id}"`;
38
+ const schema = queryHandlers.get(screen.query)?.schema;
39
+
40
+ if (screen.searchable === true && !schemaAccepts(schema, "search")) {
41
+ throw new Error(
42
+ `${prefix}: searchable: true but query "${screen.query}" has no "search" parameter in its Zod schema`,
43
+ );
44
+ }
45
+
46
+ if (
47
+ screen.searchable === false &&
48
+ schemaAccepts(schema, "search") &&
49
+ !SEARCHABLE_FALSE_WHITELIST.has(screen.id)
50
+ ) {
51
+ throw new Error(
52
+ `${prefix}: query "${screen.query}" accepts "search" but searchable: false disables it — remove searchable: false or add "${screen.id}" to SEARCHABLE_FALSE_WHITELIST`,
53
+ );
54
+ }
55
+
56
+ // sortable/paginated are derived by buildAppSchema from the query's Zod
57
+ // schema (fw#2165) — there is no separate wire type from the author-facing
58
+ // ProjectionListScreenDefinition, so hand-authoring them would otherwise be
59
+ // silently overwritten with no signal to the author. Reject outright.
60
+ if (screen.sortable !== undefined) {
61
+ throw new Error(`${prefix}: sortable is derived from the query's Zod schema, don't set it`);
62
+ }
63
+ if (screen.paginated !== undefined) {
64
+ throw new Error(`${prefix}: paginated is derived from the query's Zod schema, don't set it`);
65
+ }
66
+
67
+ const searchActive = screen.searchable !== false && schemaAccepts(schema, "search");
68
+ const sortActive = schemaAccepts(schema, "sort");
69
+ if ((searchActive || sortActive) && screen.defaultSort === undefined) {
70
+ throw new Error(`${prefix}: defaultSort required when search or sort is active`);
71
+ }
72
+ }
73
+
74
+ export function validateProjectionListScreens(features: readonly FeatureDefinition[]): void {
75
+ const queryHandlers = buildQueryHandlerMap(features);
76
+ for (const feature of features) {
77
+ for (const screen of Object.values(feature.screens)) {
78
+ if (screen.type !== "projectionList") continue;
79
+ validateOneProjectionListScreen(feature, screen, queryHandlers);
80
+ }
81
+ }
82
+ }
@@ -67,7 +67,7 @@ function validateNoWidgetRequiredField(
67
67
  function validateRowActionNavigateParams(
68
68
  featureName: string,
69
69
  screenId: string,
70
- screenType: "entityList" | "projectionList",
70
+ screenType: "entityList" | "projectionList" | "projectionDetail",
71
71
  screenEntity: string | undefined,
72
72
  action: RowAction,
73
73
  target: { readonly featureName: string; readonly screen: ScreenDefinition } | undefined,
@@ -388,6 +388,47 @@ export function validateScreens(
388
388
  );
389
389
  }
390
390
  }
391
+ // Header actions reuse RowAction (the displayed record stands in for
392
+ // the row), so the same navigate/writeHandler existence checks as
393
+ // entityList/projectionList apply. `rowClick` is rejected outright —
394
+ // a detail screen has no row to click.
395
+ if (screen.actions !== undefined) {
396
+ for (const action of screen.actions) {
397
+ if (action.kind === "navigate" && action.rowClick === true) {
398
+ throw new Error(
399
+ `[Feature ${feature.name}] Screen "${qualifyEntityName(feature.name, "screen", screenId)}" ` +
400
+ `(projectionDetail) action "${action.id}" sets rowClick: true — there is no row to ` +
401
+ `click on a detail screen. Remove rowClick.`,
402
+ );
403
+ }
404
+ if (action.kind === "navigate") {
405
+ const candidateQn = qualifyEntityName(feature.name, "screen", action.screen);
406
+ if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
407
+ throw new Error(
408
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) action "${action.id}" ` +
409
+ `navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
410
+ );
411
+ }
412
+ const target = screensByShortId.get(action.screen)?.[0];
413
+ validateRowActionNavigateParams(
414
+ feature.name,
415
+ screenId,
416
+ "projectionDetail",
417
+ screen.detailFor,
418
+ action,
419
+ target,
420
+ );
421
+ } else {
422
+ if (!allWriteHandlerQns.has(action.handler)) {
423
+ throw new Error(
424
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) action "${action.id}" ` +
425
+ `handler "${action.handler}" is not a registered write-handler. Check the QN spelling ` +
426
+ `(expected "<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`,
427
+ );
428
+ }
429
+ }
430
+ }
431
+ }
391
432
  continue;
392
433
  }
393
434
 
@@ -27,7 +27,15 @@
27
27
  // Kontext um sie zu lesen. TODO wenn das ein realer Use-Case wird:
28
28
  // `effectiveFeatures` Argument annehmen und über alle iterations filtern.
29
29
 
30
- import type { AppSchema, EntityDefinition, FeatureSchema, WorkspaceSchema } from "../ui-types";
30
+ import { ZodObject, type ZodType } from "zod";
31
+ import type {
32
+ AppSchema,
33
+ EntityDefinition,
34
+ FeatureSchema,
35
+ ProjectionListScreenDefinition,
36
+ ScreenDefinition,
37
+ WorkspaceSchema,
38
+ } from "../ui-types";
31
39
  import {
32
40
  buildConfigFeatureSchema,
33
41
  type ConfigFeatureSchema,
@@ -63,7 +71,7 @@ export function buildAppSchema(registry: Registry, options: BuildAppSchemaOption
63
71
  const featureSchema: FeatureSchema = {
64
72
  featureName,
65
73
  entities: projectEntities(feature.entities ?? {}),
66
- screens: Object.values(feature.screens),
74
+ screens: projectScreens(feature.screens, registry),
67
75
  ...(navs.length > 0 && { navs }),
68
76
  ...(contentCollections.length > 0 && { contentCollections }),
69
77
  // #1059: verbatim r.translations({keys}) — see FeatureSchema.translations
@@ -289,6 +297,51 @@ export function findNonJsonSafePath(value: unknown, path: string): string | null
289
297
  return path;
290
298
  }
291
299
 
300
+ // projectionList screens don't declare searchable/sortable/paginated as
301
+ // authoring intent — they're derived here from the bound query handler's
302
+ // Zod schema, the source of truth for what parameters it actually accepts
303
+ // (fw#2165). A hand-written `searchable: true` still wins over the derived
304
+ // default (screen.searchable ?? derived); the boot-validator (3a) rejects
305
+ // one that contradicts the schema.
306
+ function projectScreens(
307
+ screens: Readonly<Record<string, ScreenDefinition>>,
308
+ registry: Registry,
309
+ ): ScreenDefinition[] {
310
+ return Object.values(screens).map((screen) =>
311
+ screen.type === "projectionList" ? projectProjectionListScreen(screen, registry) : screen,
312
+ );
313
+ }
314
+
315
+ function projectProjectionListScreen(
316
+ screen: ProjectionListScreenDefinition,
317
+ registry: Registry,
318
+ ): ProjectionListScreenDefinition {
319
+ const schema = registry.getQueryHandler(screen.query)?.schema;
320
+ const capabilities = deriveProjectionListCapabilities(schema);
321
+ return {
322
+ ...screen,
323
+ searchable: screen.searchable ?? capabilities.searchable,
324
+ sortable: capabilities.sortable,
325
+ paginated: capabilities.paginated,
326
+ };
327
+ }
328
+
329
+ // Zod v4: a ZodObject's param names live on `.shape`. A schema that isn't a
330
+ // ZodObject (e.g. a z.union across payload shapes) yields no capability
331
+ // instead of throwing — same as a missing/unresolved query handler.
332
+ function deriveProjectionListCapabilities(schema: ZodType | undefined): {
333
+ searchable: boolean;
334
+ sortable: boolean;
335
+ paginated: boolean;
336
+ } {
337
+ const shape = schema instanceof ZodObject ? schema.shape : undefined;
338
+ return {
339
+ searchable: shape !== undefined && "search" in shape,
340
+ sortable: shape !== undefined && "sort" in shape,
341
+ paginated: shape !== undefined && ("cursor" in shape || "offset" in shape),
342
+ };
343
+ }
344
+
292
345
  function projectEntities(
293
346
  entities: Readonly<Record<string, EntityDefinition>>,
294
347
  ): Readonly<Record<string, EntityDefinition>> {
@@ -650,3 +650,61 @@ defineFeature("inventory", (r) => {
650
650
  expect(reparsed.patterns).toEqual([]);
651
651
  });
652
652
  });
653
+
654
+ // #2133 — matchArgString (shared by relation's second positional arg,
655
+ // hook's target, and useExtension's entity) only accepted a string literal
656
+ // or a name-resolving identifier. The parser itself is more permissive at
657
+ // two of those positions: useExtension's entity (round3.ts:445) and hook's
658
+ // target (hooks.ts:75) both additionally accept an inline `{ name: "..." }`
659
+ // object ref, same as the object-form fix in #2121 — so findCallForId
660
+ // couldn't locate a call authored that way. relation's second positional
661
+ // arg stays narrow on purpose: round2.ts:170 parses it via readNameLiteral,
662
+ // not readNameOrRef, so widening it would exceed what the parser accepts.
663
+ describe("callMatchesId — positional inline-ref args (#2133)", () => {
664
+ test("removePattern finds a positional useExtension whose entity is an inline { name } ref", () => {
665
+ const sf = makeSourceFile(`
666
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
667
+
668
+ defineFeature("inventory", (r) => {
669
+ r.useExtension("audit", { name: "item" });
670
+ });
671
+ `);
672
+ removePattern(sf, { kind: "useExtension", extensionName: "audit", entityName: "item" });
673
+ const reparsed = parseSourceFile(sf);
674
+ expect(reparsed.errors).toEqual([]);
675
+ expect(reparsed.patterns).toEqual([]);
676
+ });
677
+
678
+ test("removePattern finds a positional hook whose target is an inline { name } ref", () => {
679
+ const sf = makeSourceFile(`
680
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
681
+
682
+ defineFeature("hooks", (r) => {
683
+ r.hook("postSave", { name: "task" }, () => {});
684
+ });
685
+ `);
686
+ removePattern(sf, { kind: "hook", hookType: "postSave", target: "task" });
687
+ const reparsed = parseSourceFile(sf);
688
+ expect(reparsed.errors).toEqual([]);
689
+ expect(reparsed.patterns).toEqual([]);
690
+ });
691
+
692
+ // Boundary, not a gap left open by this fix: readDataLiteralNode (used by
693
+ // readNameOrRef's object-literal branch) keeps a nested Identifier as a
694
+ // RawRefSentinel instead of resolving it — same as the parser side, which
695
+ // is why this call fails to extract too (not just to patch-match).
696
+ test("does not resolve an identifier nested inside an inline-ref positional arg", () => {
697
+ const sf = makeSourceFile(`
698
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
699
+
700
+ const ENTITY_NAME = "item";
701
+
702
+ defineFeature("inventory", (r) => {
703
+ r.useExtension("audit", { name: ENTITY_NAME });
704
+ });
705
+ `);
706
+ expect(() =>
707
+ removePattern(sf, { kind: "useExtension", extensionName: "audit", entityName: "item" }),
708
+ ).toThrow(/no call found/);
709
+ });
710
+ });
@@ -374,7 +374,7 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
374
374
  );
375
375
  }
376
376
  if (matchFirstArgString(call, id.hookType)) {
377
- return matchArgString(call, 1, id.target);
377
+ return matchArgNameOrRef(call, 1, id.target);
378
378
  }
379
379
  return (
380
380
  matchObjectProperty(call, "type", id.hookType) &&
@@ -394,7 +394,7 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
394
394
  case "useExtension":
395
395
  // Positional: r.useExtension(name, entity) | Object: { name, entity }
396
396
  if (matchFirstArgString(call, id.extensionName)) {
397
- return matchArgString(call, 1, id.entityName);
397
+ return matchArgNameOrRef(call, 1, id.entityName);
398
398
  }
399
399
  return (
400
400
  matchObjectProperty(call, "name", id.extensionName) &&
@@ -433,6 +433,14 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
433
433
  // declaration (same-file or imported) to a string-literal initializer —
434
434
  // the dominant naming style in the framework's own bundled-features
435
435
  // (`r.entity(ENTITY, ...)`, `r.useExtension(EXT_X, ...)`, see #1746).
436
+ //
437
+ // Narrow on purpose: every kind's arg-0 (and relation's arg-1) is parsed
438
+ // via readNameLiteral, never readNameOrRef (see round2.ts/round3.ts) —
439
+ // widening this shared helper to readNameOrRef would let an object-form
440
+ // call's first argument (an ObjectLiteralExpression) match here too,
441
+ // short-circuiting the object-form branch in callMatchesId. Positions
442
+ // where the parser itself accepts an inline `{ name: "..." }` ref use
443
+ // `matchArgNameOrRef` below instead.
436
444
  function matchArgString(call: CallExpression, index: number, expected: string): boolean {
437
445
  const arg = call.getArguments()[index];
438
446
  if (!arg) return false;
@@ -443,6 +451,18 @@ function matchFirstArgString(call: CallExpression, expected: string): boolean {
443
451
  return matchArgString(call, 0, expected);
444
452
  }
445
453
 
454
+ // Like matchArgString, but via readNameOrRef — for the specific positional
455
+ // slots where the parser accepts an inline `{ name: "..." }` object ref in
456
+ // addition to a literal/identifier (useExtension's entity arg, hook's
457
+ // target arg; see round3.ts:445 / hooks.ts:75). Not a drop-in replacement
458
+ // for matchArgString: applying it to an arg-0 position would match an
459
+ // object-form call's first (and only) argument, see the comment above.
460
+ function matchArgNameOrRef(call: CallExpression, index: number, expected: string): boolean {
461
+ const arg = call.getArguments()[index];
462
+ if (!arg) return false;
463
+ return readNameOrRef(arg) === expected;
464
+ }
465
+
446
466
  // Object-form property values are resolved via readNameOrRef, not the
447
467
  // narrower readNameLiteral — some properties (useExtension's `entity`,
448
468
  // hook's `target`/`allOf`) accept an inline `{ name: "..." }` ref in the
@@ -201,7 +201,7 @@ describe("file validation", () => {
201
201
  });
202
202
 
203
203
  test("sniffMimeType recognizes gif, webp and pdf signatures", () => {
204
- expect(sniffMimeType(new TextEncoder().encode("GIF89a" + "x".repeat(20)))).toBe("image/gif");
204
+ expect(sniffMimeType(new TextEncoder().encode(`GIF89a${"x".repeat(20)}`))).toBe("image/gif");
205
205
  const webp = new Uint8Array([
206
206
  ...new TextEncoder().encode("RIFF"),
207
207
  0,
@@ -212,7 +212,7 @@ describe("file validation", () => {
212
212
  ...Array(20).fill(0),
213
213
  ]);
214
214
  expect(sniffMimeType(webp)).toBe("image/webp");
215
- expect(sniffMimeType(new TextEncoder().encode("%PDF-1.4" + "x".repeat(20)))).toBe(
215
+ expect(sniffMimeType(new TextEncoder().encode(`%PDF-1.4${"x".repeat(20)}`))).toBe(
216
216
  "application/pdf",
217
217
  );
218
218
  });
@@ -0,0 +1,37 @@
1
+ import { beforeAll, describe, expect, test } from "bun:test";
2
+ import { lookup } from "node:dns/promises";
3
+ import { egress } from "../egress";
4
+
5
+ // fw#2149 DoD requires proving TLS/SNI validation stays intact against a
6
+ // real HTTPS endpoint, not just a mock — the self-signed-cert tests in
7
+ // egress.test.ts pin the fetch-by-pinned-IP mechanism, this test pins it
8
+ // against a certificate chain issued by a real, publicly trusted CA.
9
+ // example.com is IANA-reserved and kept up for exactly this kind of use.
10
+ const REAL_HOST = "example.com";
11
+
12
+ let networkAvailable = true;
13
+
14
+ beforeAll(async () => {
15
+ try {
16
+ await lookup(REAL_HOST);
17
+ } catch {
18
+ networkAvailable = false;
19
+ }
20
+ });
21
+
22
+ describe("egress external: real HTTPS endpoint", () => {
23
+ test("connects through the pinned IP and validates the real certificate chain", async () => {
24
+ if (!networkAvailable) {
25
+ console.warn(
26
+ `egress real-endpoint test skipped: DNS resolution for ${REAL_HOST} failed (no network in this environment)`,
27
+ );
28
+ return;
29
+ }
30
+
31
+ const fetchIt = egress({ kind: "external" });
32
+ const res = await fetchIt(`https://${REAL_HOST}/`);
33
+
34
+ expect(res.status).toBe(200);
35
+ expect(await res.text()).toContain("Example Domain");
36
+ });
37
+ });