@cosmicdrift/kumiko-framework 0.236.1 → 0.237.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.236.1",
3
+ "version": "0.237.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>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.236.1",
201
+ "@cosmicdrift/kumiko-types": "0.237.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.236.1",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.237.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -54,11 +54,13 @@ describe("runSchemaCli — no-DB paths", () => {
54
54
  expect(cap.err).toHaveLength(0);
55
55
  });
56
56
 
57
- test("unknown subcommand falls through to usage", async () => {
57
+ test("unknown subcommand exits 1 and reports it on stderr", async () => {
58
58
  const cap = captureOut();
59
- const code = await runSchemaCli(["lolwut"], appCwd, cap.out);
60
- expect(code).toBe(0);
61
- expect(cap.log.join("\n")).toContain("Subcommands:");
59
+ const code = await runSchemaCli(["cutover"], appCwd, cap.out);
60
+ expect(code).toBe(1);
61
+ expect(cap.err.join("\n")).toContain('Unbekanntes Subcommand: "cutover"');
62
+ expect(cap.err.join("\n")).toContain("generate | validate | apply | baseline | status");
63
+ expect(cap.log).toHaveLength(0);
62
64
  });
63
65
 
64
66
  test("generate without name exits 1 with neutral usage wording", async () => {
@@ -16,7 +16,7 @@ import {
16
16
  import { createEntity, createTextField } from "../factories";
17
17
  // Barrel import, not "../entity-handlers": covers that entityListSchema is
18
18
  // actually re-exported through engine/index.ts.
19
- import { entityListSchema } from "../index";
19
+ import { entityListSchema, resolveAgentExposure } from "../index";
20
20
  import type { QueryHandlerDef, WriteHandlerDef } from "../types";
21
21
 
22
22
  const VALID_UUID = "00000000-0000-4000-8000-000000000001";
@@ -367,4 +367,56 @@ describe("registerEntityCrud", () => {
367
367
  expect(Object.keys(entities)).toEqual(["note"]);
368
368
  expect(writes.map((w) => w.name)).toEqual(["note:create"]);
369
369
  });
370
+ test("descriptions: per-verb text lands on that verb only, and drives agent exposure", () => {
371
+ const { r, writes, queries } = createCrudRegistrarMock();
372
+ registerEntityCrud(r, "note", noteEntity, {
373
+ descriptions: {
374
+ create: "Files a new note.",
375
+ list: "Lists notes newest first.",
376
+ },
377
+ });
378
+
379
+ const create = writes.find((w) => w.name === "note:create");
380
+ const update = writes.find((w) => w.name === "note:update");
381
+ const list = queries.find((q) => q.name === "note:list");
382
+ const detail = queries.find((q) => q.name === "note:detail");
383
+ if (!create || !update || !list || !detail) throw new Error("CRUD handlers missing");
384
+
385
+ expect(create.description).toBe("Files a new note.");
386
+ expect(list.description).toBe("Lists notes newest first.");
387
+ expect(update.description).toBeUndefined();
388
+ expect(detail.description).toBeUndefined();
389
+
390
+ // Fail-closed exposure is why the slot exists: only the described verbs
391
+ // reach the agent manifest.
392
+ expect(resolveAgentExposure(create, "write").expose).toBe(true);
393
+ expect(resolveAgentExposure(list, "query").expose).toBe(true);
394
+ expect(resolveAgentExposure(update, "write").expose).toBe(false);
395
+ expect(resolveAgentExposure(detail, "query").expose).toBe(false);
396
+ });
397
+
398
+ test("descriptions: per-verb entry wins over the write/read fallback", () => {
399
+ const { r, writes } = createCrudRegistrarMock();
400
+ registerEntityCrud(r, "note", noteEntity, {
401
+ write: { description: "Shared write text." },
402
+ descriptions: { create: "Files a new note." },
403
+ verbs: { list: false, detail: false },
404
+ });
405
+
406
+ expect(writes.find((w) => w.name === "note:create")?.description).toBe("Files a new note.");
407
+ expect(writes.find((w) => w.name === "note:update")?.description).toBe("Shared write text.");
408
+ });
409
+
410
+ test("agent hints on the per-handler options raise the resolved risk", () => {
411
+ const del = defineEntityDeleteHandler("note", noteEntity, {
412
+ description: "Deletes a note permanently.",
413
+ agent: { risk: "high" },
414
+ });
415
+ const create = defineEntityCreateHandler("note", noteEntity, {
416
+ description: "Files a new note.",
417
+ });
418
+
419
+ expect(resolveAgentExposure(del, "write").risk).toBe("high");
420
+ expect(resolveAgentExposure(create, "write").risk).toBe("mid");
421
+ });
370
422
  });
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
2
  import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
3
3
  import { validateBoot as validateBootRaw } from "../boot-validator";
4
4
  import { defineFeature } from "../define-feature";
@@ -358,3 +358,55 @@ describe("validateBoot — nav validation", () => {
358
358
  expect(() => validateBoot([feature])).not.toThrow();
359
359
  });
360
360
  });
361
+
362
+ describe("nav access inversion warning (fw#2640)", () => {
363
+ test("leaf role-gate disjoint from its parent section's → warns but boot still succeeds", () => {
364
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
365
+ const feature = defineFeature("shop", (r) => {
366
+ r.nav({ id: "section", label: "s", access: { roles: ["Manager"] } });
367
+ r.nav({
368
+ id: "leaf",
369
+ label: "l",
370
+ parent: "shop:nav:section",
371
+ access: { roles: ["Admin"] },
372
+ });
373
+ });
374
+ expect(() => validateBoot([feature])).not.toThrow();
375
+ expect(warnSpy).toHaveBeenCalledWith(
376
+ expect.stringContaining('Nav entry "shop:nav:leaf" requires roles [Admin]'),
377
+ );
378
+ warnSpy.mockRestore();
379
+ });
380
+
381
+ test("leaf role-gate overlapping its parent's → no warning", () => {
382
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
383
+ const feature = defineFeature("shop", (r) => {
384
+ r.nav({ id: "section", label: "s", access: { roles: ["Manager", "Admin"] } });
385
+ r.nav({
386
+ id: "leaf",
387
+ label: "l",
388
+ parent: "shop:nav:section",
389
+ access: { roles: ["Admin"] },
390
+ });
391
+ });
392
+ expect(() => validateBoot([feature])).not.toThrow();
393
+ expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("Nav entry"));
394
+ warnSpy.mockRestore();
395
+ });
396
+
397
+ test("parent open to everyone (unset access) → a role-gated leaf never warns", () => {
398
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
399
+ const feature = defineFeature("shop", (r) => {
400
+ r.nav({ id: "section", label: "s" });
401
+ r.nav({
402
+ id: "leaf",
403
+ label: "l",
404
+ parent: "shop:nav:section",
405
+ access: { roles: ["Admin"] },
406
+ });
407
+ });
408
+ expect(() => validateBoot([feature])).not.toThrow();
409
+ expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("Nav entry"));
410
+ warnSpy.mockRestore();
411
+ });
412
+ });
@@ -42,6 +42,7 @@ import {
42
42
  collectWriteHandlerQns,
43
43
  validateNavCycles,
44
44
  validateNavs,
45
+ warnOnNavAccessInversion,
45
46
  } from "./nav";
46
47
  import { validateOwnershipRules } from "./ownership";
47
48
  import { validatePiiAndRetention } from "./pii-retention";
@@ -215,6 +216,7 @@ export function validateBoot(
215
216
  }
216
217
 
217
218
  validateNavCycles(allNavQns);
219
+ warnOnNavAccessInversion(allNavQns);
218
220
  validateDefaultWorkspaceUniqueness(allWorkspaceQns);
219
221
  validateI18nSurfaceKeys(features);
220
222
  validateEntityListScreens(features);
@@ -3,7 +3,7 @@
3
3
  // here since this is a structural move, not a semantic reorg).
4
4
 
5
5
  import { qualifyEntityName } from "../qualified-name";
6
- import type { FeatureDefinition, NavDefinition, WorkspaceDefinition } from "../types";
6
+ import type { AccessRule, FeatureDefinition, NavDefinition, WorkspaceDefinition } from "../types";
7
7
 
8
8
  export function collectWriteHandlerQns(features: readonly FeatureDefinition[]): Set<string> {
9
9
  const set = new Set<string>();
@@ -99,6 +99,41 @@ export function validateNavCycles(
99
99
  }
100
100
  }
101
101
 
102
+ // undefined access or `{ openToAll: true }` both mean "visible to everyone"
103
+ // — neither has a role-set an inversion check could compare against.
104
+ function navViewerRoles(access: AccessRule | undefined): readonly string[] | undefined {
105
+ if (access === undefined || "openToAll" in access) return undefined;
106
+ return access.roles;
107
+ }
108
+
109
+ // fw#2640: warn (never throw — this is a real UX bug, not a broken ref)
110
+ // when a nav leaf's role gate is disjoint from its parent section's — no
111
+ // user who can see the section could ever see this child, so the section
112
+ // renders with zero visible children for every user who reaches it.
113
+ // Only fires when BOTH sides declare an explicit, non-empty role list:
114
+ // an unset/openToAll side is visible to everyone, so it can't invert.
115
+ export function warnOnNavAccessInversion(
116
+ allNavQns: ReadonlyMap<string, NavDefinition & { readonly featureName: string }>,
117
+ ): void {
118
+ for (const [qn, navDef] of allNavQns) {
119
+ if (navDef.parent === undefined) continue;
120
+ const parentDef = allNavQns.get(navDef.parent);
121
+ if (parentDef === undefined) continue; // dangling parent ref — validateNavs already throws for this
122
+ const parentRoles = navViewerRoles(parentDef.access);
123
+ const leafRoles = navViewerRoles(navDef.access);
124
+ if (parentRoles === undefined || leafRoles === undefined) continue;
125
+ if (parentRoles.length === 0) continue; // parent already visible to nobody — a different problem
126
+ if (leafRoles.some((role) => parentRoles.includes(role))) continue;
127
+ // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
128
+ console.warn(
129
+ `[kumiko:boot] Nav entry "${qn}" requires roles [${leafRoles.join(", ")}] but its parent ` +
130
+ `"${navDef.parent}" only allows [${parentRoles.join(", ")}] — no user who can see the ` +
131
+ `parent section could ever see this entry. If this is intentional, ignore this warning; ` +
132
+ `otherwise align the role sets.`,
133
+ );
134
+ }
135
+ }
136
+
102
137
  // Roles we recognise at boot time. The framework has no explicit
103
138
  // role-registry (r.defineRoles is a type helper only), so we synthesise
104
139
  // one from every handler-access rule plus the "all"/"system" built-ins.
@@ -78,6 +78,8 @@ export function defineWriteHandler<
78
78
  name: def.name,
79
79
  schema: def.schema,
80
80
  ...(def.access && { access: def.access }),
81
+ ...(def.description !== undefined && { description: def.description }),
82
+ ...(def.agent && { agent: def.agent }),
81
83
  ...(def.unsafeSkipTransitionGuard && {
82
84
  unsafeSkipTransitionGuard: def.unsafeSkipTransitionGuard,
83
85
  }),
@@ -172,7 +172,7 @@ function parseHandlerName<TVerb extends string>(
172
172
  export function defineEntityWriteHandler(
173
173
  name: string,
174
174
  entity: EntityDefinition,
175
- options?: { access?: AccessRule },
175
+ options?: EntityHandlerOptions,
176
176
  ): WriteHandlerDef {
177
177
  const { entityName, verb } = parseHandlerName(name, WRITE_VERBS);
178
178
  if (verb === "restore" && !entity.softDelete) {
@@ -253,6 +253,8 @@ export function defineEntityWriteHandler(
253
253
  schema,
254
254
  handler,
255
255
  ...(options?.access && { access: options.access }),
256
+ ...(options?.description !== undefined && { description: options.description }),
257
+ ...(options?.agent !== undefined && { agent: options.agent }),
256
258
  };
257
259
  }
258
260
 
@@ -282,7 +284,7 @@ function augmentDerivedFields(
282
284
  export function defineEntityQueryHandler(
283
285
  name: string,
284
286
  entity: EntityDefinition,
285
- options?: { access?: AccessRule; crossTenant?: boolean },
287
+ options?: EntityQueryHandlerOptions,
286
288
  ): QueryHandlerDef {
287
289
  const { entityName, verb } = parseHandlerName(name, QUERY_VERBS);
288
290
 
@@ -367,6 +369,8 @@ export function defineEntityQueryHandler(
367
369
  schema,
368
370
  handler,
369
371
  ...(options?.access && { access: options.access }),
372
+ ...(options?.description !== undefined && { description: options.description }),
373
+ ...(options?.agent !== undefined && { agent: options.agent }),
370
374
  // The "list" verb's executor.list() always returns { rows, nextCursor,
371
375
  // total? } (see the handler body above) — brand it so the definition
372
376
  // site documents the PagedRows contract without needing
@@ -533,10 +537,12 @@ export function registerEntityCrud(
533
537
  const resolveWriteOpts = (verb: EntityCrudVerb): EntityHandlerOptions => ({
534
538
  ...writeOpts,
535
539
  access: options?.verbAccess?.[verb] ?? writeOpts?.access,
540
+ description: options?.descriptions?.[verb] ?? writeOpts?.description,
536
541
  });
537
542
  const resolveReadOpts = (verb: EntityCrudVerb): EntityQueryHandlerOptions => ({
538
543
  ...readOpts,
539
544
  access: options?.verbAccess?.[verb] ?? readOpts?.access,
545
+ description: options?.descriptions?.[verb] ?? readOpts?.description,
540
546
  });
541
547
 
542
548
  if (verbs.create) {
@@ -439,6 +439,7 @@ export function createImagesField(
439
439
  export function createEntity<F>(def: {
440
440
  readonly table?: string;
441
441
  readonly fields: F;
442
+ readonly description?: string;
442
443
  readonly softDelete?: boolean;
443
444
  /** Event stream lives on SYSTEM_TENANT_ID (tenant-independent aggregate, e.g.
444
445
  * user) instead of the creator's tenant. See EntityDefinition.systemStream. */
package/src/schema-cli.ts CHANGED
@@ -447,6 +447,15 @@ export async function runSchemaCli(
447
447
  }
448
448
 
449
449
  default: {
450
+ // No subcommand (`kumiko schema`) is a help request → usage + exit 0.
451
+ // An unrecognized one (typo, e.g. `schema cutover`) must fail loud —
452
+ // silently exiting 0 here let a deploy's migrate step print help and
453
+ // move on without ever running a migration.
454
+ if (sub !== undefined) {
455
+ out.err(` Unbekanntes Subcommand: "${sub}"`);
456
+ out.err(" Erlaubt: generate | validate | apply | baseline | status");
457
+ return 1;
458
+ }
450
459
  out.log("");
451
460
  out.log(" Subcommands:");
452
461
  out.log(" generate <name> Schreibe neue Migration aus EntityTableMeta-Diff");